typescript/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 - with a named reason available beside it for a caller who needs to know which refusal it was.

toopo add date/add

One file, 7 075 bytes, copied into your project. It imports nothing. You get addToDate and describeAddFailure.

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.

What it is for, and what it is not

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
type DescribeAddFailure = (date: Date, duration: Duration) => AddFailureReason | null
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
}

A call fails for one of 5 reasons, and the set is frozen with the major version: "invalid-date", "unknown-field", "field-not-whole", "total-not-exact", "out-of-range".

addToDate(d, u) === null if and only if describeAddFailure(d, u) !== null, for every date d and duration u.

43 settled cases

Every one of them is named, frozen with the major version, and linkable. This is what the contract decides, one input at a time.

the calls this contract settles, every answer computed by two oracles beforehand

Baseline

addToDate('2024-01-15T10:30:00.000Z', { days: 1 }) → expected '2024-01-16T10:30:00.000Z', reason null

An ordinary day is added, and the time of day is untouched.

addToDate('2024-01-15T10:30:00.000Z', { minutes: 90 }) → expected '2024-01-15T12:00:00.000Z', reason null

Elapsed units carry into the next unit; ninety minutes is an hour and a half.

addToDate('1969-12-31T23:59:59.999Z', { milliseconds: 1 }) → expected '1970-01-01T00:00:00.000Z', reason null

The epoch is not a boundary. It is listed because implementations that branch on the sign of the timestamp get this one wrong.

End-of-month clamping

addToDate('2024-01-31T00:00:00.000Z', { months: 1 }) → expected '2024-02-29T00:00:00.000Z', reason null

A day that the target month does not have is clamped down to the last day it does. There is no 31 February, and the two defensible answers are the end of February or the overflow into March. Measured, date-fns, luxon, dayjs, moment, js-joda and Temporal under its default overflow all clamp; JavaScript's own setUTCMonth overflows, to 2 March. The contract follows the six libraries against the language, because "a month later" naming a date in March is a surprise no caller asked for.

addToDate('2023-01-31T00:00:00.000Z', { months: 1 }) → expected '2023-02-28T00:00:00.000Z', reason null

The same clamp in a common year lands on the 28th, since that is the last day.

addToDate('2024-05-31T00:00:00.000Z', { months: 1 }) → expected '2024-06-30T00:00:00.000Z', reason null

Clamping is not about February; any 31-day month followed by a 30-day one clamps.

addToDate('2024-03-31T00:00:00.000Z', { months: -1 }) → expected '2024-02-29T00:00:00.000Z', reason null

Clamping applies in both directions; going backwards is not a special case.

addToDate('2024-02-29T00:00:00.000Z', { months: -1 }) → expected '2024-01-29T00:00:00.000Z', reason null

Adding a month and removing it again does not return where it started: 31 January plus one month is 29 February, and 29 February minus one month is 29 January. The clamp discards the day of the month, and nothing remembers it. This is a consequence of clamping rather than a defect, it is what every measured library does, and it is why the round-trip property in block 4.3 excludes calendar units instead of pretending they qualify.

addToDate('2024-01-31T23:59:59.999Z', { months: 1 }) → expected '2024-02-29T23:59:59.999Z', reason null

The clamp moves the date and leaves the UTC time of day exactly as it was, down to the millisecond, including at the last instant of a day.

Leap years

addToDate('2024-02-29T00:00:00.000Z', { years: 1 }) → expected '2025-02-28T00:00:00.000Z', reason null

A leap day plus one year clamps, because the target year has no 29 February.

addToDate('2024-02-29T00:00:00.000Z', { years: 4 }) → expected '2028-02-29T00:00:00.000Z', reason null

Four years later the day exists again and nothing is clamped.

addToDate('2096-02-29T00:00:00.000Z', { years: 4 }) → expected '2100-02-28T00:00:00.000Z', reason null

The century rule bites: 2100 is divisible by 4 but not by 400, so it is not a leap year and the day is clamped. An implementation testing only `year % 4` answers 2100-02-29.

addToDate('0050-01-31T00:00:00.000Z', { months: 1 }) → expected '0050-02-28T00:00:00.000Z', reason null

A two-digit year stays a two-digit year rather than being read as a nineteen-hundreds one. It is listed because `Date.UTC` maps years 0 to 99 onto 1900 to 1999 - measured, `Date.UTC(50, 0, 1)` is 1950-01-01 where `setUTCFullYear(50, 0, 1)` is year 50 - so an implementation that builds its result through `Date.UTC` answers in the wrong century. The measured limit of this case is published rather than hidden: an implementation using `Date.UTC` only to look up the length of a month passes it, because Y and 1900 + Y are congruent modulo four and so agree on February everywhere except the century rule. The case below is the one input where they part.

addToDate('0000-01-31T00:00:00.000Z', { months: 1 }) → expected '0000-02-29T00:00:00.000Z', reason null

Year 0 is the single two-digit year where reading a month length through `Date.UTC` and reading it through `setUTCFullYear` disagree: 0 is a leap year and 1900, which `Date.UTC` maps it to, is not. Measured, February of year 0 has 29 days by `setUTCFullYear` and 28 by `Date.UTC`. Every other case in this table is congruent modulo four with the year it would be mistaken for, so all of them pass under that implementation and this one does not.

This case exists because a mutant survived without it: date-add/D-07.

Aggregation within a step

addToDate('2023-01-31T00:00:00.000Z', { months: 2 }) → expected '2023-03-31T00:00:00.000Z', reason null

Two months are added as two months, not as one month twice. Measured, adding one month twice gives 2023-03-28, because the clamp to 28 February is never undone. The contract aggregates, as every measured library does.

addToDate('2023-01-31T00:00:00.000Z', { years: 1, months: 1 }) → expected '2024-02-29T00:00:00.000Z', reason null

Years and months are one total of thirteen months, not a year then a month. Applying months first would give 2024-02-28, one day earlier, because it clamps twice.

addToDate('2024-02-25T00:00:00.000Z', { weeks: 1, days: 1 }) → expected '2024-03-04T00:00:00.000Z', reason null

Weeks and days are one total of eight days; a week is seven days and nothing else.

addToDate('2024-01-31T00:00:00.000Z', { weeks: 1 }) → expected '2024-02-07T00:00:00.000Z', reason null

A week never clamps, because it is a count of days rather than a calendar unit.

Order between the steps

addToDate('2024-01-30T23:00:00.000Z', { months: 1, hours: 2 }) → expected '2024-03-01T01:00:00.000Z', reason null

Calendar units are applied before elapsed time, and the order is observable: the calendar step clamps 30 January to 29 February, and the two hours then cross midnight into 1 March. Adding the hours first would reach 31 January, which clamps to 29 February - a different answer, a day earlier.

addToDate('2024-01-30T00:00:00.000Z', { months: 1, days: 1 }) → expected '2024-03-01T00:00:00.000Z', reason null

The same order between months and days: 30 January clamps to 29 February, then one day is added. Days first would give 31 January, then 29 February.

Negative and mixed signs

addToDate('2024-01-15T00:00:00.000Z', { days: -3 }) → expected '2024-01-12T00:00:00.000Z', reason null

A negative field subtracts; there is no separate subtraction function.

addToDate('2024-01-31T00:00:00.000Z', { months: 1, days: -1 }) → expected '2024-02-28T00:00:00.000Z', reason null

Fields may disagree in sign. Temporal rejects a mixed-sign duration outright; this contract accepts it, because `{ months: 1, days: -1 }` is how a caller writes "the day before the same date next month" and refusing it makes a common intention inexpressible. luxon and date-fns accept it and agree on this answer.

The neutral duration

addToDate('2024-01-31T12:34:56.789Z', {}) → expected '2024-01-31T12:34:56.789Z', reason null

The empty duration is the neutral element: it returns the same instant, in a new object. Temporal rejects it - measured, `TypeError: No valid fields` - on the grounds that it declares no operation; this contract accepts it, as date-fns and luxon do, because `addToDate(d, buildDuration(form))` must not fail when the form is empty.

addToDate('2024-01-31T12:34:56.789Z', { days: -0 }) → expected '2024-01-31T12:34:56.789Z', reason null

Negative zero is a whole number and adds nothing. Unlike `number/parse@1`, where the sign of zero is preserved in the result, it cannot survive here: measured, `new Date(-0).getTime()` is `0`, so `Date` erases the distinction before any implementation could keep it.

addToDate('2024-01-31T12:34:56.789Z', { days: undefined }) → expected '2024-01-31T12:34:56.789Z', reason null

A declared field set to `undefined` means zero, not an error. `{ days: form.days }` where the form has no value is ordinary TypeScript, and rejecting it would make the type lie.

Durations that are not exact whole units

addToDate('2024-01-15T00:00:00.000Z', { months: 1.5 }) → expected null, reason 'field-not-whole'

A fractional month has no meaning the contract can honour, and the established libraries disagree about it - measured, date-fns and dayjs silently truncate to one month, luxon converts the half into about fifteen days, Temporal throws. Silently truncating is the same failure as `Number("0x1F")` returning 31: an answer that looks right and is not what was asked. This is the first place in the catalogue where a contract takes a side instead of following the ecosystem.

addToDate('2024-01-15T00:00:00.000Z', { days: 0.5 }) → expected null, reason 'field-not-whole'

Half a day is rejected even though it has an exact meaning in UTC, because the rule is one rule for every field. `{ hours: 12 }` says the same thing without ambiguity.

addToDate('2024-01-15T00:00:00.000Z', { days: NaN }) → expected null, reason 'field-not-whole'

NaN is not a whole number. Adding it would produce an Invalid Date.

addToDate('2024-01-15T00:00:00.000Z', { days: Infinity }) → expected null, reason 'field-not-whole'

An infinite duration is not a whole number and has no representable result.

addToDate('2024-01-15T00:00:00.000Z', { days: 1e+21 }) → expected null, reason 'field-not-whole'

Beyond 2^53 an integer is no longer exactly representable as a double, so the arithmetic stops being the arithmetic the caller wrote. `Number.isInteger(1e21)` is true and every measured library accepts it; this contract requires `Number.isSafeInteger` instead, for the same reason it rejects a fractional month - a wrong answer delivered silently is worse than no answer.

addToDate('2024-01-15T00:00:00.000Z', { years: 9007199254740992, months: -108086391056891900 }) → expected null, reason 'field-not-whole'

Two fields past the safe range whose month total is exactly zero. Measured, `2**53` is an integer and not a safe integer, and both products are exactly representable as doubles, so their sum really is 0. An implementation guarding with `Number.isInteger` instead of `Number.isSafeInteger` therefore accepts both fields, computes a total it can represent, and returns the date unchanged - a plausible answer rather than an obviously wrong one. Every other case here is caught by the total guard even when the field guard is weakened; this is the one that requires the rule to be about the fields as written.

This case exists because a mutant survived without it: date-add/D-08.

addToDate('2024-01-15T00:00:00.000Z', { years: 9007199254740991 }) → expected null, reason 'total-not-exact'

Each field is a safe integer here, but the total is not: measured, `MAX_SAFE_INTEGER * 12` is not a safe integer, so the month total could not be computed exactly. The rule applies to the totals the contract actually adds, not only to the fields as written.

addToDate('2024-01-15T00:00:00.000Z', { milliseconds: 9007199254740991, seconds: 9007199254740991 }) → expected null, reason 'total-not-exact'

The same rule for elapsed time: two safe integers whose sum in milliseconds is not one. Past 2^53 milliseconds - about 285 000 years, far outside the range a Date can hold anyway - the sum would round, and the contract does not return values it cannot compute exactly. It carries the same reason as the month total above, because a caller repairs both by making the duration it wrote smaller.

Inputs that are not dates

addToDate('not a date', { days: 1 }) → expected null, reason 'invalid-date'

An Invalid Date in gives null out, never an Invalid Date out. `new Date("nonsense")` is a Date whose timestamp is NaN; measured, date-fns propagates it and returns another Invalid Date, which then poisons every later computation exactly as NaN does in `number/parse@1`.

addToDate('not a date', {}) → expected null, reason 'invalid-date'

The neutral duration does not rescue an invalid input. The date is checked before the duration is looked at, so no duration can make an unanswerable call answerable.

The edges of the representable range

addToDate('+275760-09-13T00:00:00.000Z', {}) → expected '+275760-09-13T00:00:00.000Z', reason null

The last representable instant is a valid input and is returned unchanged.

addToDate('+275760-09-13T00:00:00.000Z', { milliseconds: 1 }) → expected null, reason 'out-of-range'

One millisecond past the end of the range has no Date. `new Date(8.64e15 + 1)` is an Invalid Date; the contract returns null rather than hand one back.

addToDate('-271821-04-20T00:00:00.000Z', { milliseconds: -1 }) → expected null, reason 'out-of-range'

The range is symmetric, and so is the rejection.

addToDate('+275760-09-13T00:00:00.000Z', { months: 1, days: -40 }) → expected null, reason 'out-of-range'

The steps are applied in the declared order and each one must land inside the range. Adding a month leaves it, and removing forty days would come back; the contract does not allow the detour. Declaring this follows from declaring the order at all - a contract that fixed the order but let intermediate results roam would have declared half a rule.

durations no TypeScript caller can write and every JavaScript caller can

A field the contract does not define

addToDate('2024-01-15T00:00:00.000Z', { day: 1 }) → expected null, reason 'unknown-field'

A field the contract does not define is a rejection, not a no-op. `{ day: 1 }` for `{ days: 1 }` is the singular-for-plural slip everyone makes, and returning the date unchanged would be `Number("")` returning 0 in another costume: a plausible value that silently drops what the caller asked for.

addToDate('2024-01-15T00:00:00.000Z', { month: 1 }) → expected null, reason 'unknown-field'

The same slip on the calendar side, rejected for the same reason.

addToDate('2024-01-15T00:00:00.000Z', { days: 1, day: 1 }) → expected null, reason 'unknown-field'

One unknown field is enough to reject the whole call, even alongside a valid one. Applying the part that was understood would be the most dangerous answer available: it looks like it worked.

A declared field carrying the wrong type

addToDate('2024-01-15T00:00:00.000Z', { days: '1' }) → expected null, reason 'field-not-whole'

A declared field carrying the wrong type is rejected too. A string reaching this function has come from JSON or a form and has not been through the parser it needed. It shares the reason of a fractional field rather than earning one, because the same sentence is true of both: a string is not a whole number either.

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 cases 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. The settled answer is on the case's own line above, and is deliberately not repeated here. 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.

Properties

Every property below is checked on 1 000 generated cases per run, re-seeded each time.

Benchmark profiles

The shapes of input an implementation is timed on. No figures yet: there is no reference machine, and a number produced on a developer laptop would be a number with nothing behind it.

What you can check yourself

This definition is frozen. Its canonical text hashes to 1ec311d71460e8768af52858b71119e156d704023a082fe2e28b2f2afe185c97, and the 7 files of its test harness are listed inside it with their own hashes — so a copy of the harness can be checked against this definition before it is trusted, then run against any implementation, without taking our word for any of it.

Written for node, browser, bun.