Skip to content

date

Terminal window
bun add @stopcock/date

Dates 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')
  • Timestamp is a branded number so you can’t accidentally mix raw numbers with timestamps. Use fromTimestamp() for explicit conversion.
  • Every function works both ways: add(7, 'day') returns (ts) => Timestamp for pipe, or add(ts, 7, 'day') if you want data-first.
  • Business days, holidays, timezones, and interval merging are all built in.

Measured on Bun 1.3, batch operations over 10,000 timestamps.

Operationvs date-fnsvs momentvs luxon
add days1.6x faster4.5x faster8.6x faster
add months2.3x faster6.1x faster11.5x faster
startOf day1.5x faster3.6x faster6.4x faster
startOf month2.6x faster5.4x faster10x faster
endOf year3.2x faster5.5x faster48x faster
// date-fns
import { addDays, format } from 'date-fns'
const result = format(addDays(new Date(), 7), 'yyyy-MM-dd')
// dayjs
import dayjs from 'dayjs'
const result = dayjs().add(7, 'day').format('YYYY-MM-DD')
// stopcock: no Date objects, pipes naturally
import { pipe } from '@stopcock/fp'
import { now, add, format } from '@stopcock/date'
const result = pipe(now(), add(7, 'day'), format('YYYY-MM-DD'))
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"
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),
})),
)
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`
}
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"
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]]

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 | 6
type DateParts = {
year: number
month: number
day?: number
hour?: number
minute?: number
second?: number
millisecond?: number
}
now(): Timestamp
fromDate(date: Date): Timestamp
toDate(ts: Timestamp): Date
fromParts(parts: DateParts): Timestamp
fromTimestamp(ms: number): Timestamp
fromISO(iso: string): Timestamp
toTimestamp(ts: Timestamp): number
toISO(ts: Timestamp): string
getYear(ts): number getMonth(ts): number // 1-12
getDay(ts): number getWeekday(ts): Weekday // 0=Sun
getHours(ts): number getMinutes(ts): number
getSeconds(ts): number getMilliseconds(ts): number
getDayOfYear(ts): number getWeekOfYear(ts): number
getQuarter(ts): number getDaysInMonth(ts): number
getDaysInYear(ts): number isLeapYear(ts): boolean

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): number
min(dates: Timestamp[]): Timestamp max(dates: Timestamp[]): Timestamp
clamp(lo, hi): (ts) => Timestamp
isBefore(other): (ts) => boolean isAfter(other): (ts) => boolean
isEqual(other): (ts) => boolean isSameDay(other): (ts) => boolean
isSameMonth(other): (ts) => boolean isSameYear(other): (ts) => boolean
isBetween(start, end): (ts) => boolean
isWeekend(ts): boolean isWeekday(ts): boolean
isToday(ts): boolean isPast(ts): boolean
isFuture(ts): boolean isValid(ts): boolean
add(amount, unit): (ts) => Timestamp
subtract(amount, unit): (ts) => Timestamp
startOf(unit): (ts) => Timestamp
endOf(unit): (ts) => Timestamp
setYear(year): (ts) => Timestamp setMonth(month): (ts) => Timestamp
setDay(day): (ts) => Timestamp setHours(hours): (ts) => Timestamp
setMinutes(minutes): (ts) => Timestamp setSeconds(seconds): (ts) => Timestamp
diff(b, unit): (a) => number diffInDays(b): (a) => number
diffInHours(b): (a) => number diffInMinutes(b): (a) => number
diffInSeconds(b): (a) => number diffInMonths(b): (a) => number
diffInYears(b): (a) => number
roundTo(unit): (ts) => Timestamp ceilTo(unit): (ts) => Timestamp
floorTo(unit): (ts) => Timestamp snapTo(interval, unit): (ts) => Timestamp
duration(amount, unit): Duration
addDuration(d): (ts) => Timestamp subtractDuration(d): (ts) => Timestamp
toDuration(a, b): Duration durationToUnit(unit): (d) => number
scaleDuration(d, factor): Duration negateDuration(d): Duration

toDuration, scaleDuration, and negateDuration aren’t curried; they’re plain functions.

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): boolean
intersection(a1, a2, b1, b2): [Timestamp, Timestamp] | null
union(a1, a2, b1, b2): [Timestamp, Timestamp] | null
gap(a1, a2, b1, b2): [Timestamp, Timestamp] | null
mergeIntervals(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.

isBusinessDay(ts): boolean
addBusinessDays(amount): (ts) => Timestamp
subtractBusinessDays(amount): (ts) => Timestamp
businessDaysBetween(b): (a) => number
nextBusinessDay(ts): Timestamp prevBusinessDay(ts): Timestamp
addBusinessDaysWithHolidays(amount, holidays): (ts) => Timestamp
format(pattern): (ts) => string formatter(pattern): (ts) => string
parse(pattern): (input) => Timestamp parser(pattern): (input) => Timestamp
tryParse(pattern): (input) => Timestamp | null
tryParser(pattern): (input) => Timestamp | null
parseISO(input): Timestamp
Tz.utcToLocal(ts, zone): Timestamp
Tz.localToUTC(localMs, zone): Timestamp
Tz.startOf(ts, unit, zone): Timestamp
Tz.endOf(ts, unit, zone): Timestamp
Tz.add(ts, amount, unit, zone): Timestamp
Tz.format(ts, pattern, zone): string
Tz.diff(a, b, unit, zone): number
Tz.isSameDay(a, b, zone): boolean
Tz.isWeekend(ts, zone): boolean
Tz.isToday(ts, zone): boolean
Tz.getOffsetMinutes(ts, zone): number
Tz.getOffsetString(ts, zone): string