Temporal

Mental model: Temporal makes you say what kind of time you mean. A date with no time, a time with no date, an exact instant, or a wall clock reading in a named zone: four types, so the ambiguity is gone before the arithmetic starts.

Level: advanced · about 14 minutes

if (typeof Temporal === 'undefined') {
  console.log('No Temporal in this runtime.');
  console.log('Read the commented output, or load the polyfill:');
  console.log('  import { Temporal } from "temporal-polyfill";');
} else {
  console.log('Temporal is available.');
  console.log(String(Temporal.Now.plainDateISO()));      // '2024-03-05'
  console.log(String(Temporal.Now.plainTimeISO()));      // '14:07:09.123456789'
  console.log(String(Temporal.Now.instant()));           // '2024-03-05T14:07:09.123456789Z'
  console.log(Temporal.Now.timeZoneId());                // 'Europe/London'
}

Run this first. Temporal is new, so every example in this lesson checks for it.

Temporal is the replacement for Date. It is a namespace of small immutable types, and the key idea is that you choose the type that matches what you actually have. Date forced every value into "an instant rendered in the system zone", which is why a birthday could move a day and adding a month was a guess.

The Date problemWhat Temporal does
One type for every kind of timea type per kind, so you cannot mix them by accident
Mutable settersevery object is frozen; add returns a new one
Zero based monthsmonths are 1 based, and named calendars are supported
Only the system time zonenamed IANA zones as a first class value
"05/03/2024" parsed by guessworkstrict ISO 8601 parsing, or it throws
Date maths by handadd, subtract, until, since, round, compare
Millisecond precisionnanosecond precision

The types, and how to pick one

`PlainDate`
a birthday, an invoice date, a public holiday (no time, no zone)
`PlainTime`
opening hours, an alarm at 07:30 (no date, no zone)
`PlainDateTime`
a wall clock reading with no zone yet, such as a form value
`ZonedDateTime`
a meeting: 09:00 on Tuesday in Asia/Tokyo
`Instant`
a log timestamp, an exact point on the timeline
`Duration`
a length of time: 1 hour 30 minutes
`PlainYearMonth`
a card expiry, a billing month
`PlainMonthDay`
a recurring date such as 25 December
if (typeof Temporal === 'undefined') {
  console.log('Temporal missing, expected output is in the comments.');
} else {
  const d = Temporal.PlainDate.from('2024-01-31');

  console.log(d.year, d.month, d.day);        // 2024 1 31, month is 1 based
  console.log(String(d.add({ months: 1 })));  // '2024-02-29', clamped, not 2 March
  console.log(String(d.add({ days: 1 })));    // '2024-02-01'
  console.log(String(d.subtract({ years: 1 }))); // '2023-01-31'
  console.log(String(d));                     // '2024-01-31', d never changed
  console.log(d.dayOfWeek, d.daysInMonth, d.inLeapYear); // 3 31 true
}

PlainDate arithmetic, and the answer Date could not give you.

const d = Temporal.PlainDate.from('2024-01-31');
console.log(d.add({ months: 1 }).toString());

The default overflow behaviour is "constrain", so an impossible day is clamped to the last day of the target month, giving 29 February in a leap year. Date rolls over instead and gives 2 March. If you would rather be told, pass { overflow: "reject" } and it throws.

if (typeof Temporal === 'undefined') {
  console.log('Temporal missing, expected output is in the comments.');
} else {
  const a = Temporal.PlainDate.from('2024-01-31');
  const b = Temporal.PlainDate.from('2024-03-01');

  console.log(Temporal.PlainDate.compare(a, b));      // -1, sortable
  console.log(a.equals(Temporal.PlainDate.from({ year: 2024, month: 1, day: 31 }))); // true
  console.log(String(a.until(b, { largestUnit: 'day' })));   // 'P30D'
  console.log(String(a.until(b, { largestUnit: 'month' })));  // 'P1M1D'
  console.log(String(b.since(a, { largestUnit: 'day' })));    // 'P30D'

  try {
    a.add({ months: 1 }, { overflow: 'reject' });
  } catch (err) {
    console.log(err.constructor.name);                 // 'RangeError'
  }
}

Comparing, differencing and being explicit about overflow.

Duration: a length, not a point

if (typeof Temporal === 'undefined') {
  console.log('Temporal missing, expected output is in the comments.');
} else {
  const d = Temporal.Duration.from({ minutes: 90 });

  console.log(String(d));                       // 'PT1H30M' after balancing on output
  console.log(d.total({ unit: 'hours' }));      // 1.5
  console.log(d.total({ unit: 'seconds' }));    // 5400
  console.log(String(Temporal.Duration.from('PT2H15M').add({ minutes: 50 })));
  // 'PT3H5M'
  console.log(String(Temporal.Duration.from({ seconds: 3661 }).round({ largestUnit: 'hour' })));
  // 'PT1H1M1S'
  console.log(Temporal.Duration.compare(d, Temporal.Duration.from({ hours: 2 }))); // -1
}

Zones and instants

if (typeof Temporal === 'undefined') {
  console.log('Temporal missing, expected output is in the comments.');
} else {
  // British Summer Time starts at 01:00 on 31 March 2024.
  const before = Temporal.ZonedDateTime.from('2024-03-31T00:30[Europe/London]');

  console.log(String(before.add({ hours: 1 })));
  // '2024-03-31T02:30:00+01:00[Europe/London]', the clock jumped

  console.log(String(before.add({ days: 1 })));
  // '2024-04-01T00:30:00+01:00[Europe/London]', same wall clock, 23 hours later

  const instant = before.toInstant();
  console.log(instant.epochMilliseconds);            // a plain number
  console.log(String(instant.toZonedDateTimeISO('Asia/Tokyo')));
  // the same moment, seen from Tokyo
}

The same wall clock arithmetic, done correctly across a daylight saving change.

Date: two lines, three assumptions

const d = new Date(2024, 0, 31);
d.setMonth(d.getMonth() + 1);
// 2 March, in the system zone,
// and d was mutated

Temporal: one line, no assumptions

const d = Temporal.PlainDate
  .from('2024-01-31')
  .add({ months: 1 });
// 2024-02-29, no zone involved,
// the original is untouched

The difference is not brevity. It is that the Temporal version has no hidden dependency on the machine it runs on, so it gives the same answer in London, Tokyo and CI.

Migrating from Date

  1. Cross the boundary once Convert at the edges of your system: Date in, Temporal inside, Date out only if an old API demands it.
  2. Classify each stored value Every date field in your database is either an instant or a calendar value. Write the answer next to the column name before you touch the code.
  3. Replace the helpers, not the calls Your addDays, startOfMonth and formatDate helpers become one liners. The call sites do not change.
  4. Ship behind a feature check Until support is universal, load the polyfill when the global is missing so the same code path runs everywhere.
With DateWith Temporal
Date.now()Temporal.Now.instant()
new Date() for todayTemporal.Now.plainDateISO()
new Date(2024, 2, 5)Temporal.PlainDate.from({ year: 2024, month: 3, day: 5 })
d.getTime()instant.epochMilliseconds
copy.setDate(copy.getDate() + 1)date.add({ days: 1 })
Math.round((b - a) / 86400000)a.until(b, { largestUnit: "day" }).days
d.toISOString().slice(0, 10)plainDate.toString()
a.getTime() === b.getTime()a.equals(b)
sorting by +dTemporal.PlainDate.compare

Try it yourself

Temporal, if you have it

if (typeof Temporal === 'undefined') {
  console.log('No Temporal here, doing it the Date way:');
  const d = new Date(Date.UTC(2024, 0, 31));
  const copy = new Date(d.getTime());
  copy.setUTCMonth(copy.getUTCMonth() + 1);
  console.log('Date says:', copy.toISOString().slice(0, 10)); // '2024-03-02'
  console.log('Temporal would say: 2024-02-29');
} else {
  const d = Temporal.PlainDate.from('2024-01-31');
  console.log(String(d.add({ months: 1 })));
  console.log(String(d.with({ day: 1 })));
  console.log(d.until(Temporal.PlainDate.from('2024-12-25'), { largestUnit: 'month' }).toString());

  const meeting = Temporal.ZonedDateTime.from('2024-06-03T09:00[Asia/Tokyo]');
  console.log(String(meeting.withTimeZone('Europe/London')));
}

Change the zone to your own, then work out how many hours apart the two zoned times are. If Temporal is missing, the fallback branch does the same job with Date so you can compare the code.

The two rules, without Temporal

const daysInMonth = (year, month) =>
  new Date(Date.UTC(year, month, 0)).getUTCDate(); // month is 1 based here

console.log(daysInMonth(2024, 2), daysInMonth(2023, 2)); // 29 28

const balance = (totalSeconds) => ({
  hours: Math.trunc(totalSeconds / 3600),
  minutes: Math.trunc((totalSeconds % 3600) / 60),
  seconds: totalSeconds % 60,
});

console.log(balance(5400));  // { hours: 1, minutes: 30, seconds: 0 }
console.log(balance(3661));  // { hours: 1, minutes: 1, seconds: 1 }
console.log(balance(-3661)); // watch the signs, and watch for -0

These are the exercises in miniature. Add a withDay that clamps, then try a startOfNextMonth.

Exercises

Balance a duration

Temporal balances a Duration so that minutes and seconds stay under 60. Implement that rule on plain data: balance({ hours, minutes, seconds }) returns a new object with the same total, where minutes and seconds are 0 to 59. Missing fields count as 0. A negative total makes every non-zero field negative (Temporal durations have a single sign), and a zero field must be 0, never -0.

Add months the Temporal way

Implement Temporal's month arithmetic on plain data. addMonths({ year, month, day }, n, overflow = "constrain") returns a new object with month 1 based. When the resulting day does not exist, "constrain" clamps it to the last day of the target month, and "reject" throws a RangeError. n may be negative or larger than 12, and the input object must not be mutated.

Check yourself

Assuming Temporal is available, what does this log?
'2024-02-29' — The default overflow is "constrain", which clamps an impossible day to the last valid one, and 2024 is a leap year. Date would roll over to 2 March instead. Pass { overflow: "reject" } if a clamp would be a bug in your domain.
You are storing the moment a server request finished, to be compared with other servers. Which type?
Temporal.Instant — An Instant is an exact point on the timeline with no calendar or zone attached, which is exactly what a log timestamp is. A ZonedDateTime would add a zone you do not need, and the Plain types have no fixed point at all so they cannot be compared across machines.
In Temporal, Temporal.PlainDate.from({ year: 2024, month: 2, day: 1 }) is which date?
1 February 2024 — Temporal months are 1 based, so 2 is February. This is the one place Temporal deliberately breaks compatibility with Date, where 1 means February. Fields are also validated, so month: 13 throws rather than rolling into next year.
Why does Temporal.Duration.from({ months: 1 }).total({ unit: "hours" }) throw?
A month has no fixed number of hours without a reference date — One month is 28 to 31 days, and one day can be 23 or 25 hours across a daylight saving change. The API asks for relativeTo rather than picking an average, which is precisely the guessing that made Date arithmetic unreliable.

Common mistakes

  • Writing Temporal calls with no feature detection or polyfill, then finding out from a bug report.
  • Carrying the zero based month habit over: Temporal months are 1 based.
  • Expecting add to mutate. Every Temporal object is frozen and every operation returns a new one.
  • Reaching for ZonedDateTime for a birthday, which reintroduces the shifting date bug Temporal exists to remove.
  • Assuming a Duration in months or days can be converted to hours without a relativeTo date.

Takeaways

  • Temporal replaces one ambiguous type with several precise ones, so you name the kind of time you have.
  • Every object is immutable: add, subtract, with and round all return new values.
  • Months are 1 based, parsing is strict ISO, and precision goes to nanoseconds.
  • until and since produce a Duration, and largestUnit decides whether you get "30 days" or "1 month and 1 day".
  • Month end arithmetic is explicit: constrain clamps, reject throws.
  • Until support is universal, feature detect and load the polyfill, and convert with toTemporalInstant at the boundaries.