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 problem | What Temporal does |
|---|---|
| One type for every kind of time | a type per kind, so you cannot mix them by accident |
| Mutable setters | every object is frozen; add returns a new one |
| Zero based months | months are 1 based, and named calendars are supported |
| Only the system time zone | named IANA zones as a first class value |
"05/03/2024" parsed by guesswork | strict ISO 8601 parsing, or it throws |
| Date maths by hand | add, subtract, until, since, round, compare |
| Millisecond precision | nanosecond 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 untouchedThe 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
- Cross the boundary once Convert at the edges of your system:
Datein, Temporal inside,Dateout only if an old API demands it. - 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.
- Replace the helpers, not the calls Your
addDays,startOfMonthandformatDatehelpers become one liners. The call sites do not change. - 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 Date | With Temporal |
|---|---|
Date.now() | Temporal.Now.instant() |
new Date() for today | Temporal.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 +d | Temporal.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
overflowis"constrain", which clamps an impossible day to the last valid one, and 2024 is a leap year.Datewould 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— AnInstantis an exact point on the timeline with no calendar or zone attached, which is exactly what a log timestamp is. AZonedDateTimewould add a zone you do not need, and thePlaintypes 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
2is February. This is the one place Temporal deliberately breaks compatibility withDate, where1means February. Fields are also validated, somonth: 13throws 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
relativeTorather than picking an average, which is precisely the guessing that madeDatearithmetic 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
addto mutate. Every Temporal object is frozen and every operation returns a new one. - Reaching for
ZonedDateTimefor a birthday, which reintroduces the shifting date bug Temporal exists to remove. - Assuming a
Durationin months or days can be converted to hours without arelativeTodate.
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,withandroundall return new values. - Months are 1 based, parsing is strict ISO, and precision goes to nanoseconds.
untilandsinceproduce aDuration, andlargestUnitdecides whether you get "30 days" or "1 month and 1 day".- Month end arithmetic is explicit:
constrainclamps,rejectthrows. - Until support is universal, feature detect and load the polyfill, and convert with
toTemporalInstantat the boundaries.