toopo

date/add

  • stable contract
  • TS · npm

Add a duration to a Date and get a new Date back, in UTC, without mutating the input, or null when the call cannot be answered exactly - with a named reason available beside it for a caller who needs to know which refusal it was.

npx toopo add date/add

7 075 bytes, one file

0 imports

43 settled cases

Frozen contract

The signature and behaviour of add will never change — not in a minor release, not in a major one. Updates may only fix internal defects while preserving the contract, held to its 43 settled cases on every commit.

What it does

Adds a duration to a Date in JavaScript and TypeScript and returns a new Date, without mutating the one it was given and without reading the machine time zone. The built-in way to do this mutates: d.setDate(d.getDate() + 7) changes the caller's object in place, and it reads the calendar of whichever time zone the process happens to run in, so the same code answers differently on a laptop in Paris and a server in UTC. It also has no notion of a month that is too short - setUTCMonth on 31 January lands on 2 or 3 March rather than the end of February. This contract computes in absolute UTC time, clamps a day that does not exist in the target month down to the last day that does, applies calendar units before elapsed time, and returns null rather than an Invalid Date when the input is not a date, when the duration carries a field this contract does not declare, when the duration is not made of exact whole units, or when the result falls outside the range a Date can hold. It never returns an Invalid Date, the value that propagates as NaN through every later computation and surfaces far away from the call that produced it. Which of those refusals happened is published by a second export rather than folded into the return value, so a caller who only needs a date is not made to unwrap one.

Absolute instants, shifted by durations written in whole units: an expiry, a reminder, a retention window, a report period, a timestamp read from a database or an API. The arithmetic is UTC throughout, so no daylight-saving transition is ever crossed, skipped or repeated, and one day is always exactly 24 hours. That exclusion is deliberate rather than an oversight: reading the wall clock of a particular place, where a civil day can last 23 or 25 hours, requires knowing which place, and a function that silently borrows the process time zone is impure and answers differently on two machines running the same code. Calendar arithmetic in a named zone is a separate, later contract that will take the zone as a parameter. This one is not a parser, not a formatter, and not a difference-between-two-dates function.

Signature

type AddToDate = (date: Date, duration: Duration) => Date | null

Source

lib/toopo/date/add.ts

// typescript/date/add@1 - https://toopo.dev/typescript/date/add@1/
// Copyright (c) 2026 Mathis Perron. SPDX-License-Identifier: MIT-0

/**
 * `date/add@1` - add a duration to a Date and get a new Date back, in UTC, without mutating the
 * input, or `null` when the call cannot be answered exactly.
 *
 * Written to be read: correctness and obviousness come before speed.
 *
 * Every accessor below belongs to the `getUTC*` / `setUTC*` family, and that is load-bearing. The
 * local-time family reads and writes the calendar of whichever time zone the process happens to run
 * in, which would make this function answer differently on two machines running the same code.
 *
 * Failure is `null`, and `describeAddFailure` publishes the reason beside it. Both derive from one
 * private analysis, so this module holds a single traversal of the arithmetic and the two exports
 * cannot drift.
 */

type Duration = {
  readonly years?: number | undefined
  readonly months?: number | undefined
  readonly weeks?: number | undefined
  readonly days?: number | undefined
  readonly hours?: number | undefined
  readonly minutes?: number | undefined
  readonly seconds?: number | undefined
  readonly milliseconds?: number | undefined
}

type DurationField = keyof Duration

type AddFailureReason =
  | 'invalid-date'
  | 'unknown-field'
  | 'field-not-whole'
  | 'total-not-exact'
  | 'out-of-range'

/**
 * The single source both exports derive from, private because it is not the shape this module
 * publishes: a caller sees `Date | null` and asks for the reason only when it needs one.
 *
 * One function rather than two, so that a call fails exactly when it has a description - there is one
 * traversal of the arithmetic here, so the two exports cannot disagree about which calls fail. It
 * also keeps one public call to one traversal, which matters because the work that would be repeated
 * is month arithmetic.
 */
type AddAnalysis =
  | { readonly ok: true; readonly date: Date }
  | { readonly ok: false; readonly reason: AddFailureReason }

const DECLARED_FIELDS: readonly DurationField[] = [
  'years',
  'months',
  'weeks',
  'days',
  'hours',
  'minutes',
  'seconds',
  'milliseconds',
]

const MILLISECONDS_PER_WEEK = 604_800_000
const MILLISECONDS_PER_DAY = 86_400_000
const MILLISECONDS_PER_HOUR = 3_600_000
const MILLISECONDS_PER_MINUTE = 60_000
const MILLISECONDS_PER_SECOND = 1_000

/** An absent field and a field explicitly set to `undefined` both mean zero. */
const valueOf = (duration: Duration, field: DurationField): number => duration[field] ?? 0

const isExactWholeNumber = (value: number | undefined): boolean =>
  value === undefined || Number.isSafeInteger(value)

const hasOnlyDeclaredFields = (duration: Duration): boolean =>
  Object.keys(duration).every((key) => (DECLARED_FIELDS as readonly string[]).includes(key))

/**
 * The length of a month, found by asking for day zero of the one after it.
 *
 * Built with `setUTCFullYear` rather than `Date.UTC`, because `Date.UTC` maps years 0 to 99 onto
 * 1900 to 1999 - `Date.UTC(50, 0, 1)` is 1950-01-01 - and February is exactly the month whose length
 * that mapping can get wrong.
 */
const lastDayOfMonth = (year: number, monthIndex: number): number => {
  const probe = new Date(0)
  probe.setUTCFullYear(year, monthIndex + 1, 0)

  return probe.getUTCDate()
}

/**
 * The calendar step: the month total is applied to the UTC year and month at once, and a day the
 * target month does not have is clamped down to the last one it does. `setUTCFullYear` accepts a
 * month index outside 0-11 and carries it into the year, so no year arithmetic is needed here, and
 * it leaves the time of day untouched.
 */
const monthShiftedTimestamp = (start: number, totalMonths: number): number => {
  const shifted = new Date(start)
  const year = shifted.getUTCFullYear()
  const targetMonth = shifted.getUTCMonth() + totalMonths
  const clampedDay = Math.min(shifted.getUTCDate(), lastDayOfMonth(year, targetMonth))

  shifted.setUTCFullYear(year, targetMonth, clampedDay)

  return shifted.getTime()
}

const elapsedMilliseconds = (duration: Duration): number =>
  valueOf(duration, 'weeks') * MILLISECONDS_PER_WEEK +
  valueOf(duration, 'days') * MILLISECONDS_PER_DAY +
  valueOf(duration, 'hours') * MILLISECONDS_PER_HOUR +
  valueOf(duration, 'minutes') * MILLISECONDS_PER_MINUTE +
  valueOf(duration, 'seconds') * MILLISECONDS_PER_SECOND +
  valueOf(duration, 'milliseconds')

const analyse = (date: Date, duration: Duration): AddAnalysis => {
  // No longer measured redundant. Under `null` alone this line changed no answer on any input tried,
  // including 200 000 random draws, because NaN propagates to the final range check by every path.
  // It now decides between two different reasons: an unparsed input is `invalid-date`, and the range
  // check would call it `out-of-range`.
  const start = date.getTime()
  if (!Number.isFinite(start)) return { ok: false, reason: 'invalid-date' }

  if (!hasOnlyDeclaredFields(duration)) return { ok: false, reason: 'unknown-field' }
  if (!DECLARED_FIELDS.every((field) => isExactWholeNumber(duration[field]))) {
    return { ok: false, reason: 'field-not-whole' }
  }

  const totalMonths = valueOf(duration, 'years') * 12 + valueOf(duration, 'months')
  const elapsed = elapsedMilliseconds(duration)

  // Each field is a safe integer on its own, but a total need not be, and past 2^53 these sums round
  // instead of adding. Rejecting there keeps every answer exact; the widest shift it costs is one
  // spanning the whole representable range, which no caller can want more than approximately.
  //
  // One reason for both totals, and so one statement. A caller who overflowed the month total and
  // one who overflowed the elapsed total repair the same way - they look at the duration they wrote
  // and make it smaller - so telling them apart would be a literal frozen into the major that no
  // caller acts on.
  if (!Number.isSafeInteger(totalMonths) || !Number.isSafeInteger(elapsed)) {
    return { ok: false, reason: 'total-not-exact' }
  }

  const shifted = totalMonths === 0 ? start : monthShiftedTimestamp(start, totalMonths)
  const result = new Date(shifted + elapsed)

  return Number.isFinite(result.getTime())
    ? { ok: true, date: result }
    : { ok: false, reason: 'out-of-range' }
}

export const addToDate = (date: Date, duration: Duration): Date | null => {
  const analysis = analyse(date, duration)

  return analysis.ok ? analysis.date : null
}

/**
 * Why a call could not be answered, or `null` when it can be.
 *
 * Only `out-of-range` is decided after the month arithmetic has run; the four others are settled by
 * inspecting the arguments. A caller that asks for the reason after a refusal therefore repeats the
 * whole computation on exactly one of the five paths and almost none of it on the other four.
 */
export const describeAddFailure = (date: Date, duration: Duration): AddFailureReason | null => {
  const analysis = analyse(date, duration)

  return analysis.ok ? null : analysis.reason
}

Try it on your own input

This calls addToDate on whatever you type. What you type into a field is the value, character for character, and the form opens on an-ordinary-day so there is a call that works to edit. duration is written as a literal instead, exactly the way the examples above are written, because what it takes is an object with named fields, which a line of text cannot spell. What comes back is what the function answered, under the call it was made from — invisible characters are named there, so two inputs that look alike on screen do not print alike. When it answers nothing, describeAddFailure is called on the same input and its reason is printed underneath: the two exports are one surface, and every input this contract turns down answers addToDate alike.

The JavaScript this runs is date/add's own reference.ts with its types stripped. That is neither the file the registry serves nor the file its digest covers: both are TypeScript, and no browser runs TypeScript. It is also the only part of this page that needs JavaScript at all.