Skip to content

2 min read

A calendar date is not an instant

How a one-line date format shipped the wrong day, and why the fix was a type rather than a patch.

This site rendered Jul 13, 2026 for a post dated the fourteenth. The datetime attribute beside it said 2026-07-14. Both came from the same field.

The offending line was ordinary enough to read past:

new Date(post.date).toLocaleDateString("en-CA", { /* ... */ });

Two types wearing one costume

"2026-07-14" names a box on a wall calendar. It has no hour, and it does not identify a moment: “the fourteenth” begins at different instants in Auckland and in Toronto.

new Date("2026-07-14") does not preserve that. The ECMAScript spec parses a date-only ISO string as UTC midnight, producing a specific point on the timeline. toLocaleDateString then projects that point back onto a calendar, and with no timeZone option, it uses the host’s. Toronto sits five hours behind UTC in winter, so midnight UTC is still 7pm on the thirteenth.

The value made a round trip through a type that could not hold it, and came back a day short.

Why it survived CI

The build was green. Types checked, tests passed, and the site deployed. The bug reproduced only where the clock disagreed with UTC, which meant it appeared on my laptop and vanished in CI, the failure mode most likely to be dismissed as a fluke.

Nothing in the pipeline had an opinion about the output. That gap was worth more attention than the date bug itself.

The fix is a type

Adding timeZone to the formatter would have corrected the symptom. Instead the calendar date became its own type, with the only route in going through a parser:

declare const isoDateBrand: unique symbol;
export type IsoDate = string & { readonly [isoDateBrand]: true };

export const parseIsoDate = (raw: string): Parsed<IsoDate> => { /* ... */ };

The brand is erased at runtime: an IsoDate is a plain string. Its entire value is that you cannot obtain one without going through the parser, so any value of that type has already been checked. Shape alone is not enough: 2026-02-31 matches YYYY-MM-DD and names no day, which a round trip through Date.UTC catches, since the constructor quietly overflows it into March.

Conversions to an instant are now explicit and always take a zone, defaulting to the project’s own rather than the host’s. The build produces identical output in Toronto, Auckland, and CI.

The general shape

Whenever a value converts through another type to be displayed, the conversion takes a parameter. Supply it, or the environment will, and the environment does not know what you meant.