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.

43 named edge cases, settled and frozen. TypeScript source copied into your project: one file, 7 075 bytes, no dependencies.

[Toopo](../../../)

# 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.

- `never mutates its arguments — checked`

  Applicable here, where it was not on \`number/parse@1\`: \`Date\` is a mutable object and the implementation a developer writes first - \`date.setUTCDate(date.getUTCDate() + n)\` - mutates the caller's object and returns it. The property asserts both halves, that the input holds the same instant afterwards and that the result is a different object, because an implementation that mutates and returns its argument satisfies output equality.
- `deterministic — checked`

  Violable in practice, and witnessed by D-18 of the battery: an implementation that shifts the caller's own Date and returns a copy answers from a moved instant on the second call. Two candidate witnesses were measured and rejected, because a property pinned to a flaky or silent mutant is worse than an open gap. A clock read is not observable here - Date.now has millisecond resolution and the two calls this property makes are microseconds apart, so the mutant would be green almost always and red by accident. A cache is not observable either: consulted first, it is primed by the property's own first call and returns that same answer to the second. D-02, which mutates the input and hands the object back rather than a copy, leaves this property green for a third reason - measured - because both calls then return the one object and it is compared against itself. This property is ordered under \`no ambient input\` rather than independent of it: every mutant measured to redden it reddens that one too, and the memoise-last mutant reddens that one and not this. D-22 is that mutant here.
- `no ambient input — checked`

  The substantive property of this contract. The ambient input at stake is the process time zone: \`getMonth\` and \`setDate\` read and write the local calendar, so an implementation built on them is a different function on a machine in Paris and a machine in UTC. Measured, such an implementation diverges from the UTC answer on 411 of 1000 pseudo-random draws once the zone is varied - and on 0 of 1000 if the zone is never varied. The zone instance is witnessed by D-05, which reads and writes the local calendar. The call history is tested as a second instance of the same property, and witnessed by D-19: the Date used to look up the length of a month hoisted to module scope, so the year the previous call left in it decides the next one. State that advances with every call is the only thing this instance can see - a cache consulted first is primed by the probe and stays invisible, measured on \`number/parse@1\` where two such caches survive the whole battery. D-22 is the witness that belongs to this instance alone: measured, it reddens here and leaves determinism green.
- `no ambient output — not applicable`

  Not reachable by a property - a test cannot observe a write that happened before it ran, and a correct memoising cache is indistinguishable from a defect by behaviour alone. Here it is a static analysis requirement instead, declared in \`staticAnalysisRequirements\` above.

## 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.

- `elapsed-time-only — accepted`

  Durations with no calendar unit, which skip the month arithmetic entirely. The dominant shape in expiry and timeout code.
- `calendar-only — accepted`

  Durations that touch only the month arithmetic, on days that never clamp.
- `clamping — accepted`

  Calendar arithmetic that lands on a day the target month does not have, isolating the cost of finding the length of a month. Measured separately because the clamp is the one branch an implementation can get expensive.
- `every-field — accepted`

  A duration carrying all eight fields, the worst case for validation and summation.
- `rejected-inputs — rejected`

  Calls that must return null. Measured separately because an implementation may take a very different path when it refuses, and callers validating untrusted input hit that path most.

## 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.
