Intl.DateTimeFormat vs. Temporal.ZonedDateTime for DST-Safe Time Zone Conversion in TypeScript
0 reputation · 03 Aug 2022, 02:19 UTC
0 reputation · 03 Aug 2022, 02:19 UTC
TypeScript's lib adds no time zone abstraction to Date — methods like getHours and toISOString use only the host zone or UTC — so regional conversion comes down to two documented paths: built-in Intl.DateTimeFormat with its timeZone option, or the Stage 3 Temporal.ZonedDateTime reached through the @js-temporal/polyfill and its shipped type declarations.
The constraint is DST-safe day arithmetic. A Date stores only a UTC instant, so shifting a scheduled time by one day in a target zone means re-formatting through Intl after each mutation to recover the correct wall clock. Temporal.ZonedDateTime.add({ days: 1 }) applies the zone's rules directly instead.
Neither path checks zone identifiers at compile time. Intl.DateTimeFormatOptions.timeZone is typed as plain string, so a misspelled IANA name such as 'Asia/Kolkatta' passes strict compilation and, per ECMA-402, only surfaces as a RangeError when the formatter is constructed. The polyfill's declarations accept the same stringly-typed identifiers.
Given that, which path is the sounder default for regional scheduling logic while Temporal remains Stage 3 and outside the default lib? Is a user-level branded IanaZone type with a runtime allowlist worth layering onto either choice to catch typos before deployment? And do runtime ICU/tzdata differences in alias handling shift the comparison?
34775 reputation · 03 Aug 2022, 13:43 UTC
For regional scheduling logic that mutates dates (add days, find next occurrence, shift across DST transitions), Temporal.ZonedDateTime is the sounder default today. Its .add({ days: 1 }) applies the target zone's rules directly, while Intl.DateTimeFormat only formats — day arithmetic requires a fragile mutate-format-parse round-trip.
For pure display formatting, Intl.DateTimeFormat remains the sounder default: zero dependencies, universal support, and identical DST output for the same ICU version.
A user-level branded IanaZone type with a runtime allowlist is worth layering onto either choice. It catches typos (e.g., 'Asia/Kolkatta') at startup for ~2–3 KB gzipped (full IANA list) or less for a curated regional subset. The guard only works if you enforce it via a constructor-only module export or lint rule; as IanaZone bypasses it.
Runtime ICU/tzdata differences do shift the comparison. The polyfill bundles its own tzdata (IANA 2024a in current 0.4.x), while Intl uses the host ICU. In the same process, the same instant can format differently between the two APIs. Pick one authority per application.
Temporal.ZonedDateTime stores the instant, the zone, and the calendar. .add({ days: 1 }) advances the wall-clock date in that zone, respecting DST transitions, skipped hours, and repeated hours. Example: in America/New_York on 2026-11-01 (fall-back), adding one day to 01:30 EDT preserves 01:30 EST the next day.
Intl.DateTimeFormat has no arithmetic. To shift a scheduled time by one day you must:
Date (lossy, ambiguous near transitions)Date (adds 24×60×60×1000 ms, not "one day in zone")Intl to get the new wall timeThis round-trip fails silently when the mutated instant lands in a different offset.
Intl.DateTimeFormatOptions.timeZone and the polyfill's declarations both type the zone as string. A misspelled IANA ID passes tsc --strict and throws RangeError only when the formatter or Temporal.ZonedDateTime is constructed — typically at first request in production.
IanaZone Guard (Recommended)// iana-zone.ts
export type IanaZone = string & { readonly __brand: unique symbol };
const ALLOWLIST = new Set<string>([
// curated subset for your deployed regions, or load from IANA zone.tab
'America/New_York',
'America/Los_Angeles',
'Europe/London',
'Asia/Kolkata',
'Asia/Tokyo',
'Australia/Sydney',
// ...
]);
export function toIanaZone(input: string): IanaZone {
if (!ALLOWLIST.has(input)) {
throw new Error(`Invalid IANA zone: ${input}`);
}
return input as IanaZone;
}
// usage
const zone = toIanaZone(config.timeZone); // throws at startup if typo
const fmt = new Intl.DateTimeFormat(undefined, { timeZone: zone, hour: 'numeric' });
const zdt = Temporal.ZonedDateTime.from(`2026-10-12T12:00:00Z[${zone}]`);
Cost: ~2–3 KB gzipped for the full IANA list (~600 zones). A curated subset for your markets is smaller. Enforce via eslint-plugin-import rule blocking direct string literals in zone positions.
| Source | tzdata Version | Update Cadence | Risk |
|---|---|---|---|
| Host ICU (Node 18/20/22, Chrome, Firefox, Safari) | Varies by release | OS/browser update | Outdated rules for recent changes (e.g., Asia/Tehran 2022, America/Sao_Paulo 2019) |
@js-temporal/polyfill 0.4.x | IANA 2024a | Polyfill release | Newer than host ICU in many environments; same instant formats differently |
If your application uses both APIs, the same timestamp can render different wall times. Choose one:
--icu-data-dir pointing to a current ICU build, or require Node ≥ 20 (ICU 73+).npm ls @js-temporal/polyfill → check package.json or release notes for IANA version.const instant = '2026-11-01T06:00:00Z'; // America/New_York fall-back
console.log('Intl:', new Intl.DateTimeFormat(undefined, { timeZone: 'America/New_York', hour: 'numeric', hour12: false }).format(new Date(instant)));
console.log('Temporal:', Temporal.ZonedDateTime.from(`${instant}[America/New_York]`).add({ days: 1 }).toString());
const zones = new Set(Intl.supportedValuesOf('timeZone')) — note this matches host ICU, not Temporal's bundled data.What is your minimum supported runtime? If you must support Node < 20, older browsers, or a locked-down WebView where polyfill globals are forbidden, Intl is your only viable path without a custom build step. In that case, invest in a robust mutate-format-parse helper and the branded IanaZone guard.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.