date
bun add @stopcock/dateDates are branded numbers (Unix ms timestamps). No Date objects get allocated anywhere, it’s all integer arithmetic. All multi-arg functions are dual-form.
import { pipe } from '@stopcock/fp'import { now, add, startOf, format, isWeekend } from '@stopcock/date'
const nextMonday = pipe(now(), startOf('week'), add(7, 'day'))
format(nextMonday, 'YYYY-MM-DD')Timestampis a brandednumberso you can’t accidentally mix raw numbers with timestamps. UsefromTimestamp()for explicit conversion.- Every function works both ways:
add(7, 'day')returns(ts) => Timestampforpipe, oradd(ts, 7, 'day')if you want data-first. - Business days, holidays, timezones, and interval merging are all built in.
Benchmarks
Section titled “Benchmarks”Measured on Bun 1.3, batch operations over 10,000 timestamps.
| Operation | vs date-fns | vs moment | vs luxon |
|---|---|---|---|
| add days | 1.6x faster | 4.5x faster | 8.6x faster |
| add months | 2.3x faster | 6.1x faster | 11.5x faster |
| startOf day | 1.5x faster | 3.6x faster | 6.4x faster |
| startOf month | 2.6x faster | 5.4x faster | 10x faster |
| endOf year | 3.2x faster | 5.5x faster | 48x faster |
| Operation | vs date-fns | vs moment | vs luxon |
|---|---|---|---|
| YYYY-MM-DD HH:mm:ss | 7.3x faster | 3.2x faster | 5.9x faster |
| ddd, DD MMM YYYY hh:mm A | 8.4x faster | 3.4x faster | 6.7x faster |
| Operation | vs date-fns | vs moment | vs luxon |
|---|---|---|---|
| diffInDays | 73x faster | 45x faster | 308x faster |
| diffInMonths | 13x faster | 25x faster | 45x faster |
| diffInYears | 10x faster | 25x faster | 44x faster |
Comparisons
Section titled “Comparisons”// date-fnsimport { addDays, format } from 'date-fns'const result = format(addDays(new Date(), 7), 'yyyy-MM-dd')
// dayjsimport dayjs from 'dayjs'const result = dayjs().add(7, 'day').format('YYYY-MM-DD')
// stopcock: no Date objects, pipes naturallyimport { pipe } from '@stopcock/fp'import { now, add, format } from '@stopcock/date'const result = pipe(now(), add(7, 'day'), format('YYYY-MM-DD'))// date-fns: needs date-fns-tz, separate adapterimport { toZonedTime } from 'date-fns-tz'import { isWeekend } from 'date-fns'isWeekend(toZonedTime(new Date(), 'Asia/Tokyo'))
// dayjs: needs timezone + utc pluginsimport dayjs from 'dayjs'import utc from 'dayjs/plugin/utc'import tz from 'dayjs/plugin/timezone'dayjs.extend(utc); dayjs.extend(tz)dayjs().tz('Asia/Tokyo').day() === 0 || dayjs().tz('Asia/Tokyo').day() === 6
// stopcock: built-in, no pluginsimport { Tz, now } from '@stopcock/date'Tz.isWeekend(now(), 'Asia/Tokyo')// date-fns: no built-in business day support// You need date-fns-business-days or manual loops
// stopcock: built-inimport { now, addBusinessDaysWithHolidays, format, fromISO } from '@stopcock/date'
const holidays = [fromISO('2026-12-25'), fromISO('2026-01-01')]const delivery = addBusinessDaysWithHolidays(now(), 10, holidays)format(delivery, 'YYYY-MM-DD')Real-world patterns
Section titled “Real-world patterns”Invoice due date
Section titled “Invoice due date”import { pipe } from '@stopcock/fp'import { fromISO, add, isWeekend, nextBusinessDay, format, isBefore, now } from '@stopcock/date'
const invoiceDueDate = (issuedAt: string, netDays: number) => { const due = pipe(fromISO(issuedAt), add(netDays, 'day')) return isWeekend(due) ? nextBusinessDay(due) : due}
const due = invoiceDueDate('2026-03-15', 30)const overdue = isBefore(due, now())format(due, 'DD MMM YYYY') // "14 Apr 2026"Calendar grid
Section titled “Calendar grid”import { pipe } from '@stopcock/fp'import * as A from '@stopcock/fp/array'import { daysIn, getWeekday, format, isToday, isWeekend } from '@stopcock/date'
const calendarMonth = (year: number, month: number) => pipe( daysIn(year, month), A.map((day) => ({ label: format('D')(day), weekday: getWeekday(day), today: isToday(day), weekend: isWeekend(day), })), )Time-ago formatting
Section titled “Time-ago formatting”import { now, diffInMinutes, diffInHours, diffInDays } from '@stopcock/date'
const timeAgo = (ts: Timestamp) => { const mins = diffInMinutes(now(), ts) if (mins < 1) return 'just now' if (mins < 60) return `${mins}m ago` const hrs = diffInHours(now(), ts) if (hrs < 24) return `${hrs}h ago` return `${diffInDays(now(), ts)}d ago`}Cross-timezone scheduling
Section titled “Cross-timezone scheduling”import { pipe } from '@stopcock/fp'import { fromISO, Tz } from '@stopcock/date'
const standup = pipe(fromISO('2026-04-03'), Tz.add(9, 'hour', 'Europe/London'))
Tz.format(standup, 'HH:mm z', 'America/New_York') // "04:00 EDT"Tz.format(standup, 'HH:mm z', 'Asia/Tokyo') // "17:00 JST"Merging overlapping bookings
Section titled “Merging overlapping bookings”import { fromISO, mergeIntervals } from '@stopcock/date'
const bookings = [ [fromISO('2026-06-01'), fromISO('2026-06-05')], [fromISO('2026-06-03'), fromISO('2026-06-08')], [fromISO('2026-06-10'), fromISO('2026-06-12')],] as [Timestamp, Timestamp][]
mergeIntervals(bookings)// [[Jun 1 → Jun 8], [Jun 10 → Jun 12]]API reference
Section titled “API reference”type Timestamp = number & { readonly [TimestampBrand]: true }type Duration = number & { readonly [DurationBrand]: true }type DateUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6type DateParts = { year: number month: number day?: number hour?: number minute?: number second?: number millisecond?: number}Creation
Section titled “Creation”now(): TimestampfromDate(date: Date): TimestamptoDate(ts: Timestamp): DatefromParts(parts: DateParts): TimestampfromTimestamp(ms: number): TimestampfromISO(iso: string): TimestamptoTimestamp(ts: Timestamp): numbertoISO(ts: Timestamp): stringExtraction
Section titled “Extraction”getYear(ts): number getMonth(ts): number // 1-12getDay(ts): number getWeekday(ts): Weekday // 0=SungetHours(ts): number getMinutes(ts): numbergetSeconds(ts): number getMilliseconds(ts): numbergetDayOfYear(ts): number getWeekOfYear(ts): numbergetQuarter(ts): number getDaysInMonth(ts): numbergetDaysInYear(ts): number isLeapYear(ts): booleanComparison & predicates
Section titled “Comparison & predicates”Every signature below is dual: the data-first form on the left works, and
omitting the timestamp/leading argument returns a curried function instead
(shown here since it’s the preferred, consistent-with-the-rest-of-the-docs
form). min, max, isWeekend, isWeekday, isToday, isPast, isFuture,
and isValid take one argument and aren’t curried functions of anything else.
compare(a, b): numbermin(dates: Timestamp[]): Timestamp max(dates: Timestamp[]): Timestampclamp(lo, hi): (ts) => TimestampisBefore(other): (ts) => boolean isAfter(other): (ts) => booleanisEqual(other): (ts) => boolean isSameDay(other): (ts) => booleanisSameMonth(other): (ts) => boolean isSameYear(other): (ts) => booleanisBetween(start, end): (ts) => booleanisWeekend(ts): boolean isWeekday(ts): booleanisToday(ts): boolean isPast(ts): booleanisFuture(ts): boolean isValid(ts): booleanArithmetic
Section titled “Arithmetic”add(amount, unit): (ts) => Timestampsubtract(amount, unit): (ts) => TimestampstartOf(unit): (ts) => TimestampendOf(unit): (ts) => TimestampsetYear(year): (ts) => Timestamp setMonth(month): (ts) => TimestampsetDay(day): (ts) => Timestamp setHours(hours): (ts) => TimestampsetMinutes(minutes): (ts) => Timestamp setSeconds(seconds): (ts) => Timestampdiff(b, unit): (a) => number diffInDays(b): (a) => numberdiffInHours(b): (a) => number diffInMinutes(b): (a) => numberdiffInSeconds(b): (a) => number diffInMonths(b): (a) => numberdiffInYears(b): (a) => numberRounding
Section titled “Rounding”roundTo(unit): (ts) => Timestamp ceilTo(unit): (ts) => TimestampfloorTo(unit): (ts) => Timestamp snapTo(interval, unit): (ts) => TimestampDuration
Section titled “Duration”duration(amount, unit): DurationaddDuration(d): (ts) => Timestamp subtractDuration(d): (ts) => TimestamptoDuration(a, b): Duration durationToUnit(unit): (d) => numberscaleDuration(d, factor): Duration negateDuration(d): DurationtoDuration, scaleDuration, and negateDuration aren’t curried; they’re
plain functions.
Ranges & intervals
Section titled “Ranges & intervals”range(end, step, unit): (start) => Timestamp[]rangeBy(end, stepFn): (start) => Timestamp[]daysIn(year, month): Timestamp[] weekdaysIn(year, month): Timestamp[]sequence(count, step, unit): (start) => Timestamp[]overlaps(a1, a2, b1, b2): boolean contains(start, end, point): booleanintersection(a1, a2, b1, b2): [Timestamp, Timestamp] | nullunion(a1, a2, b1, b2): [Timestamp, Timestamp] | nullgap(a1, a2, b1, b2): [Timestamp, Timestamp] | nullmergeIntervals(intervals): [Timestamp, Timestamp][]daysIn and weekdaysIn take a year and a month, not a range. overlaps,
contains, intersection, union, gap, and mergeIntervals aren’t
curried.
Business days
Section titled “Business days”isBusinessDay(ts): booleanaddBusinessDays(amount): (ts) => TimestampsubtractBusinessDays(amount): (ts) => TimestampbusinessDaysBetween(b): (a) => numbernextBusinessDay(ts): Timestamp prevBusinessDay(ts): TimestampaddBusinessDaysWithHolidays(amount, holidays): (ts) => TimestampFormatting & parsing
Section titled “Formatting & parsing”format(pattern): (ts) => string formatter(pattern): (ts) => stringparse(pattern): (input) => Timestamp parser(pattern): (input) => TimestamptryParse(pattern): (input) => Timestamp | nulltryParser(pattern): (input) => Timestamp | nullparseISO(input): TimestampTimezone (Tz namespace)
Section titled “Timezone (Tz namespace)”Tz.utcToLocal(ts, zone): TimestampTz.localToUTC(localMs, zone): TimestampTz.startOf(ts, unit, zone): TimestampTz.endOf(ts, unit, zone): TimestampTz.add(ts, amount, unit, zone): TimestampTz.format(ts, pattern, zone): stringTz.diff(a, b, unit, zone): numberTz.isSameDay(a, b, zone): booleanTz.isWeekend(ts, zone): booleanTz.isToday(ts, zone): booleanTz.getOffsetMinutes(ts, zone): numberTz.getOffsetString(ts, zone): string