bastianplsfix

Dates and times

Use Temporal. It is in Deno, it is typed, it needs no flag and no polyfill, and it fixes every complaint anyone has had about Date. That is a different answer from the one you will read almost everywhere else: for twenty years the advice was to avoid the built-in Date and reach for a library, and for the last few years it was to wait for Temporal. The waiting is over.

Temporal gives you a handful of types and asks you to pick the one that matches what you actually mean: Instant for a moment in time with no place attached; PlainDate, PlainTime, and PlainDateTime for a reading off a calendar or a clock with no zone at all; ZonedDateTime for a moment in a place; and Duration for a length of time. Choosing between them is a design decision rather than a convenience, and being made to choose is most of the value. You still need to know what a Date is, because APIs hand them to you, and this entry covers enough to convert one and to recognise the bugs.

Create programs/dates-and-times.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Below the import, add the date most of the page revisits:

const january27 = Temporal.PlainDate.from("2077-01-27");

Follow the page as you add and revise the runnable examples below it.

the types, by what they represent

Deno.test("the types, by what they represent", () => {
assertStrictEquals(
Temporal.Instant.from("2077-01-27T11:00Z").toString(),
"2077-01-27T11:00:00Z",
);

assertStrictEquals(january27.toString(), "2077-01-27");
assertStrictEquals(Temporal.PlainTime.from("11:00").toString(), "11:00:00");
assertStrictEquals(
Temporal.PlainDateTime.from("2077-01-27T11:00").toString(),
"2077-01-27T11:00:00",
);

assertStrictEquals(
Temporal.ZonedDateTime.from("2077-01-27T12:00[Europe/Paris]").toString(),
"2077-01-27T12:00:00+01:00[Europe/Paris]",
);

assertStrictEquals(
Temporal.Duration.from({ hours: 25, minutes: 30 }).toString(),
"PT25H30M",
);
});
Check programs/dates-and-times.test.ts
running 1 test from ./programs/dates-and-times.test.ts
the types, by what they represent ... ok (444µs)

ok | 1 passed | 0 failed (2ms)

Every type has a static from that parses a string or takes an object, and a toString that produces ISO 8601, so the text form and the value form are the same thing in two shapes, and round-tripping is lossless. The distinction to internalise is plain against zoned. A birthday is a PlainDate, the 27th of January wherever you are, and asking what moment it began is a different question. A log entry is an Instant, one moment, with what the clock said locally a presentation detail. A meeting is a ZonedDateTime, 2pm in Paris, which is a specific moment and a specific wall-clock reading, and it stays 2pm in Paris even if the offset changes.

the current one of each

Deno.test("the current one of each", () => {
assertStrictEquals(typeof Temporal.Now.instant().epochMilliseconds, "number");
assert(Temporal.Now.plainDateISO() instanceof Temporal.PlainDate);
assert(Temporal.Now.zonedDateTimeISO() instanceof Temporal.ZonedDateTime);
assertStrictEquals(typeof Temporal.Now.timeZoneId(), "string");
});
the current one of each ... ok (87µs)

Temporal.Now is where now lives, one function per type rather than one Date you then interrogate. Note Temporal.Now.timeZoneId(), which tells you the zone you are running in, a thing Date never exposed and could only hint at through an offset.

values are immutable

Deno.test("values are immutable", () => {
const tomorrow = january27.add({ days: 1 });

assertStrictEquals(tomorrow.toString(), "2077-01-28");
assertStrictEquals(january27.toString(), "2077-01-27");

const legacy = new Date(0);
legacy.setFullYear(2000);
assertStrictEquals(legacy.getFullYear(), 2000);
});
values are immutable ... ok (45µs)

Every Temporal operation returns a new value and none of them has a setter, where a Date has twenty setters and each one changes the object every holder can see, the sharing problem from the values and references page applied to something people pass around constantly. This is half the argument on its own: a Date stored on an object and handed to a function is a mutable value you no longer control, and a PlainDate is not.

months start at 1

Temporal counts January as month 1. Hand the same numbers to Date.UTC and predict the ISO string:

Deno.test("months start at 1", () => {
assertStrictEquals(january27.month, 1);
assertStrictEquals(january27.day, 27);

assertStrictEquals(
new Date(Date.UTC(2077, 1, 27)).toISOString(),
"2077-01-27T00:00:00.000Z",
);
});
Check programs/dates-and-times.test.ts
running 4 tests from ./programs/dates-and-times.test.ts
...
months start at 1 ... FAILED (8ms)

ERRORS

months start at 1 => ./programs/dates-and-times.test.ts:56:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 2077-02-27T00:00:00.000Z
+ 2077-01-27T00:00:00.000Z

FAILURES

months start at 1 => ./programs/dates-and-times.test.ts:56:6

FAILED | 3 passed | 1 failed (10ms)

error: Test failed

February. Date's zero-based month is the single most notorious mistake in the standard library, and it is worth knowing it was never a design decision anyone defended. Temporal fixes it, along with the other numbering oddity: Date has a getFullYear because getYear returned the year minus 1900, and Temporal just has year. Correct the prediction by writing January the Date way, as 0:

Deno.test("months start at 1", () => {
assertStrictEquals(january27.month, 1);
assertStrictEquals(january27.day, 27);
assertStrictEquals(january27.year, 2077);

assert(
Temporal.PlainDate.from({ year: 2077, month: 1, day: 27 }).equals(
january27,
),
);

assertStrictEquals(
new Date(Date.UTC(2077, 0, 27)).toISOString(),
"2077-01-27T00:00:00.000Z",
);
assertStrictEquals(new Date(Date.UTC(2077, 0, 27)).getUTCMonth(), 0);
});
months start at 1 ... ok (151µs)

an impossible date is constrained, or rejected on request

Ask for the 31st of February. Date would roll it forward into March, so predict Temporal's answer:

Deno.test("an impossible date is constrained, or rejected on request", () => {
assertStrictEquals(
Temporal.PlainDate.from({ year: 2077, month: 2, day: 31 }).toString(),
"2077-03-03",
);
});
Check programs/dates-and-times.test.ts
running 5 tests from ./programs/dates-and-times.test.ts
...
an impossible date is constrained, or rejected on request ... FAILED (9ms)

ERRORS

an impossible date is constrained, or rejected on request => ./programs/dates-and-times.test.ts:75:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 2077-02-28
+ 2077-03-03

FAILURES

an impossible date is constrained, or rejected on request => ./programs/dates-and-times.test.ts:75:6

FAILED | 4 passed | 1 failed (11ms)

error: Test failed

Neither March nor an error: the day clamped to the end of the month. That is deliberate, and it is what you want when you are adding a month to the 31st of January, which is the common case and has no correct answer. When the input came from a user rather than from arithmetic you want the other behaviour, and {overflow: "reject"} gives you a RangeError. Correct the prediction, pin the refusal, and keep the Date rollover for contrast:

Deno.test("an impossible date is constrained, or rejected on request", () => {
assertStrictEquals(
Temporal.PlainDate.from({ year: 2077, month: 2, day: 31 }).toString(),
"2077-02-28",
);

assertThrows(
() =>
Temporal.PlainDate.from({ year: 2077, month: 2, day: 31 }, {
overflow: "reject",
}),
RangeError,
"day value is not in a valid range",
);

assertStrictEquals(
new Date(Date.UTC(2077, 1, 31)).toISOString(),
"2077-03-03T00:00:00.000Z",
);
});
an impossible date is constrained, or rejected on request ... ok (329µs)

The last pin is the wrong prediction made real: Date silently rolls into the 3rd of March and offers no way to object.

arithmetic knows about calendars

Deno.test("arithmetic knows about calendars", () => {
assertStrictEquals(
january27.add({ months: 1, days: 3 }).toString(),
"2077-03-02",
);

assertStrictEquals(
january27.until(Temporal.PlainDate.from("2077-03-02")).toString(),
"P34D",
);
assertStrictEquals(
january27.until(Temporal.PlainDate.from("2077-03-02"), {
largestUnit: "month",
}).toString(),
"P1M3D",
);
});
arithmetic knows about calendars ... ok (79µs)

add takes an object of units rather than a number of milliseconds, so one month means one month rather than thirty days, and February's 28 days are accounted for. until gives you a Duration, and largestUnit decides how it is expressed: thirty-four days, or one month and three days, the same length and different answers to different questions. None of this is expressible with Date, where adding a month means reading the month, adding one, writing it back, and hoping.

and about time zones, which is the part Date cannot do

The 10th of March 2024 is when New York moved to daylight saving time. Add one day to midnight, and predict the elapsed hours between the two midnights:

Deno.test("and about time zones, which is the part Date cannot do", () => {
const newYorkMidnight = Temporal.ZonedDateTime.from(
"2024-03-10T00:00[America/New_York]",
);
const nextDay = newYorkMidnight.add({ days: 1 });

assertStrictEquals(newYorkMidnight.until(nextDay).total("hours"), 24);
});
Check programs/dates-and-times.test.ts
running 7 tests from ./programs/dates-and-times.test.ts
...
and about time zones, which is the part Date cannot do ... FAILED (8ms)

ERRORS

and about time zones, which is the part Date cannot do => ./programs/dates-and-times.test.ts:116:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 23
+ 24

FAILURES

and about time zones, which is the part Date cannot do => ./programs/dates-and-times.test.ts:116:6

FAILED | 6 passed | 1 failed (12ms)

error: Test failed

Twenty-three. Adding one day to midnight gives midnight the next day, and the offset moves from -05:00 to -04:00 to make that true, so one calendar day was twenty-three hours long. Both answers are correct, and they are answers to different questions: the same time tomorrow is calendar arithmetic, and twenty-four hours from now is clock arithmetic. Temporal distinguishes them, so add({days: 1}) and add({hours: 24}) give different results in exactly the cases where they should, as the last pin below shows by landing at one in the morning. Date has one kind of arithmetic, milliseconds, and no concept of a time zone beyond local and UTC, so this case is not merely awkward with a Date, it is unrepresentable, and code that tries produces a meeting an hour early twice a year. Correct the prediction:

Deno.test("and about time zones, which is the part Date cannot do", () => {
const newYorkMidnight = Temporal.ZonedDateTime.from(
"2024-03-10T00:00[America/New_York]",
);
const nextDay = newYorkMidnight.add({ days: 1 });

assertStrictEquals(
nextDay.toString(),
"2024-03-11T00:00:00-04:00[America/New_York]",
);
assertStrictEquals(newYorkMidnight.offset, "-05:00");
assertStrictEquals(nextDay.offset, "-04:00");

assertStrictEquals(newYorkMidnight.until(nextDay).total("hours"), 23);

assertStrictEquals(
newYorkMidnight.add({ hours: 24 }).toString(),
"2024-03-11T01:00:00-04:00[America/New_York]",
);
});
and about time zones, which is the part Date cannot do ... ok (72µs)

comparison is a method, because these are values

Deno.test("comparison is a method, because these are values", () => {
const same = Temporal.PlainDate.from("2077-01-27");

assert(january27.equals(same));
assertFalse(january27 === same);

assertStrictEquals(Temporal.PlainDate.compare(january27, same), 0);
assertStrictEquals(
Temporal.PlainDate.compare(january27, january27.add({ days: 1 })),
-1,
);

const dates = [january27.add({ days: 5 }), january27];
assertEquals(
dates.toSorted(Temporal.PlainDate.compare).map(String),
["2077-01-27", "2077-02-01"],
);

assertFalse(new Date(1) === new Date(1));
assert(new Date(1) < new Date(2));
});
comparison is a method, because these are values ... ok (204µs)

Two PlainDates for the same day are two objects, so === is false, the identity rule from the values and references page and unavoidable for anything that is not a primitive. So there is .equals() for equality and a static compare for ordering, the latter returning the negative, zero, or positive the ordering and sorting page describes, which is why dates.toSorted(Temporal.PlainDate.compare) works directly. The Date comparison at the bottom is worth a moment: === fails there too, and < works, by accident, because the relational operators coerce to numbers and a Date coerces to its millisecond value. Temporal deliberately does not support that, since a coercion that works for < and not for == is a trap rather than a feature.

durations are values too

Deno.test("durations are values too", () => {
const long = Temporal.Duration.from({ hours: 25, minutes: 30 });

assertStrictEquals(long.total("hours"), 25.5);
assertStrictEquals(
long.round({ largestUnit: "days" }).toString(),
"P1DT1H30M",
);

assertThrows(
() => Temporal.Duration.from({ months: 1 }).total("days"),
RangeError,
);
});
durations are values too ... ok (62µs)

A Duration is a length expressed in units rather than a number, and it keeps the units you gave it, so twenty-five and a half hours stays that way until you ask for something else: total collapses it to one unit as a number, and round re-expresses it, here as one day, one hour, and thirty minutes. The distinction matters because a duration in months has no fixed length: P1M is twenty-eight to thirty-one days depending on where you start, so total("days") on one needs a reference point, and the last pin shows Temporal refusing to guess rather than assuming a month length. The refusal is a RangeError whose message Deno currently leaves nearly blank, which is why the assertion checks only the type.

a zone conversion is one call

Deno.test("a zone conversion is one call", () => {
const paris = Temporal.ZonedDateTime.from("2077-01-27T12:00[Europe/Paris]");

assertStrictEquals(
paris.withTimeZone("Asia/Tokyo").toString(),
"2077-01-27T20:00:00+09:00[Asia/Tokyo]",
);
assertStrictEquals(paris.toInstant().toString(), "2077-01-27T11:00:00Z");
});
a zone conversion is one call ... ok (31µs)

withTimeZone keeps the moment and changes the place, so noon in Paris is 20:00 in Tokyo. toInstant throws the place away and keeps the moment. Those two calls are the whole of time zone handling for most programs, and both are impossible with a Date, which knows only the zone the process happens to be running in.

what a Date is, and how its parsing surprises you

Deno.test("what a Date is, and how its parsing surprises you", () => {
assertStrictEquals(new Date(0).getTime(), 0);

assertStrictEquals(
new Date("2077-01-27").getTime(),
new Date("2077-01-27T00:00Z").getTime(),
);

const local = new Date("2077-01-27T00:00");
const utc = new Date("2077-01-27T00:00Z");
assertStrictEquals(
local.getTime() - utc.getTime(),
local.getTimezoneOffset() * 60_000,
);

assertStrictEquals(new Date(12, 1, 22).getFullYear(), 1912);

assertStrictEquals(String(new Date("nonsense")), "Invalid Date");
assert(Number.isNaN(new Date("nonsense").getTime()));
assert(Number.isNaN(Date.parse("nonsense")));
});
what a Date is, and how its parsing surprises you ... ok (33µs)

A Date is one number, milliseconds since the start of 1970 in UTC, and everything else is getters that interpret that number either in UTC or in whatever zone the process is running in, with nowhere to put a third choice. The parsing rules are where this becomes a bug rather than a limitation: a date-only string is UTC, and the same string with a time on it is local, so new Date("2077-01-27") and new Date("2077-01-27T00:00") are different moments, and the second is a different day depending on where the code runs. The third assertion pins their difference as exactly the process's own offset, which is why it passes in every zone while proving the two differ in most of them. The fix when you are stuck with Date is to always append a Z or an offset, and the better fix is not to be stuck with it. Two more, preserved for compatibility: a year under 100 gets 1900 added to it, and an unparseable string produces an Invalid Date, an object that exists, stringifies to "Invalid Date", and whose getTime() is NaN, so the failure spreads through arithmetic silently rather than throwing, in the way the sentinels page describes for NaN generally.

convert at the boundary, store strings

Deno.test("convert at the boundary, store strings", () => {
function toTemporal(date: Date): Temporal.Instant {
return Temporal.Instant.fromEpochMilliseconds(date.getTime());
}

function toDate(instant: Temporal.Instant): Date {
return new Date(instant.epochMilliseconds);
}

const legacy = new Date(Date.UTC(2077, 0, 27));
const instant = toTemporal(legacy);
assertStrictEquals(instant.toString(), "2077-01-27T00:00:00Z");
assertStrictEquals(toDate(instant).getTime(), legacy.getTime());

assertStrictEquals(
JSON.stringify({ when: january27 }),
'{"when":"2077-01-27"}',
);
assert(
Temporal.PlainDate.from(JSON.parse('{"when":"2077-01-27"}').when).equals(
january27,
),
);
});
convert at the boundary, store strings ... ok (57µs)

Two lines each, both lossless, and Instant is the right meeting point because it is the one Temporal type that means the same thing a Date does: never let a Date deeper into your own code than these two functions. For storage, every Temporal type has a toJSON, so it needs no replacer and no special case, the hook the json page names as the cleanest extension point in that API. Note that the string keeps the type: "2077-01-27" parses back as a PlainDate and could not be mistaken for a moment, where an epoch number loses that, one reason to prefer the string even when both would work.

formatting for a person is Intl's job

Deno.test("formatting for a person is Intl's job", () => {
assertStrictEquals(january27.toLocaleString("en-GB"), "27/01/2077");

const formatter = new Intl.DateTimeFormat("en-GB", {
dateStyle: "long",
timeZone: "UTC",
});
assertStrictEquals(
formatter.format(new Date(Date.UTC(2077, 0, 27))),
"27 January 2077",
);

const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
assertStrictEquals(relative.format(-1, "day"), "yesterday");
});
formatting for a person is Intl's job ... ok (6ms)

toISOString and toString are for machines and for nobody respectively. Intl.DateTimeFormat is what a reader should see, pinned here with an explicit timeZone so the answer does not depend on where the suite runs, and Intl.RelativeTimeFormat gives you yesterday without a table of special cases. Intl.DurationFormat exists too, and all three are in Deno.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
the types, by what they represent ... ok (444µs)
the current one of each ... ok (49µs)
values are immutable ... ok (48µs)
months start at 1 ... ok (141µs)
an impossible date is constrained, or rejected on request ... ok (313µs)
arithmetic knows about calendars ... ok (51µs)
and about time zones, which is the part Date cannot do ... ok (48µs)
comparison is a method, because these are values ... ok (200µs)
durations are values too ... ok (58µs)
a zone conversion is one call ... ok (31µs)
what a Date is, and how its parsing surprises you ... ok (41µs)
convert at the boundary, store strings ... ok (57µs)
formatting for a person is Intl's job ... ok (7ms)
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 600 passed | 0 failed (1s)

Thirteen tests, and the practice is short. Temporal for everything you write, picking the type deliberately: PlainDate for a birthday, ZonedDateTime for an appointment, Instant for a log line, Duration for a length. Convert at the boundary, and never let a Date into your own code. Store ISO strings, which is what it serialises to anyway. Formatting for a person is Intl's job, not either library's. And the libraries were the right answer until now: Luxon, Day.js, date-fns, and the rest existed because the built-in type was unusable, code using them is not wrong, and new code does not need them.