Legacy Date Methods vs Modern Alternatives

This guide maps every commonly-used Date method to its safer Intl or Temporal equivalent so you can retire legacy patterns one call site at a time. Part of Intl API & Legacy Date Patterns.

What actually breaks

The native Date object has powered browser timekeeping since ES1, but three design decisions cause production incidents: it reads and writes wall-clock values in the host timezone with no way to name an arbitrary IANA zone, its setter methods mutate state in place, and its string parser is implementation-defined for anything that is not a full ISO 8601 instant. The visible symptoms are familiar — a calendar event shows the wrong hour after the clocks change, a "30 days from now" reminder silently shifts by an hour across a DST boundary, and a server-rendered timestamp does not match what the browser renders, producing a React or Vue hydration mismatch. None of these fail loudly. They pass unit tests on a developer machine set to UTC and break only for users in zones that observe daylight saving.

The fix is not "wrap Date more carefully." It is to move display logic onto Intl.DateTimeFormat (locale- and zone-aware, no mutation) and calendar/arithmetic logic onto Temporal (immutable, explicit zones, boundary-safe). The diagram below is the translation table you will reach for during a migration.

The mapping from legacy accessors to their modern counterparts is the core of this page:

Legacy Date methods mapped to Temporal and Intl equivalentsFour rows map getMonth zero-indexed, getYear, mutating setHours, and Date.parse on the left to their immutable, zone-explicit Temporal or Intl replacements on the right, with a central arrow indicating the migration direction.Legacy Date (mutable, host-zone)Modern (immutable, zone-explicit)migrate →getMonth() → 0–11January is 0, off-by-one trapplainDate.month → 1–12January is 1getYear()returns year minus 1900plainDate.yearfull proleptic yearsetHours(h) mutateschanges object in place.with({ hour: h })returns a new valueDate.parse(str)implementation-definedTemporal.PlainDate.from(str)strict ISO, throws on bad inputReplace at each call site; route new logic through Temporal/Intl

API reference: legacy method → modern equivalent

Legacy Date call Behavior / hazard Modern equivalent Return type
d.getMonth() 0-indexed (Jan = 0) pd.month number (1–12)
d.getDate() day-of-month, host zone pd.day number (1–31)
d.getFullYear() / d.getYear() getYear() returns year − 1900 pd.year number
d.getHours() wall-clock hour in host zone only zdt.hour number
d.getTimezoneOffset() minutes, sign inverted, current moment only zdt.offset / zdt.offsetNanoseconds string / number
d.setHours(h) mutates d in place zdt.with({ hour: h }) new ZonedDateTime
Date.parse(s) / new Date(s) implementation-defined for non-ISO input Temporal.PlainDate.from(s) PlainDate (throws on bad input)
d.toLocaleString() (no timeZone) defaults to host zone → SSR drift new Intl.DateTimeFormat(loc, { timeZone }) cached formatter

pd is a Temporal.PlainDate, zdt a Temporal.ZonedDateTime. The single most cited gotcha — replacing getMonth() and getDate() — has its own walkthrough in replace getMonth and getDate with Temporal.

Approach A: the legacy Date solution

This is the pattern most existing code uses. It works on a UTC-configured CI box and breaks for real users.

// Legacy: format a stored instant as "M/D/YYYY" for a US user.
const stored = new Date('2024-03-10T02:30:00Z'); // an absolute instant (UTC)
// getMonth() is 0-indexed, so +1 is mandatory — a perennial off-by-one source.
const month = stored.getMonth() + 1;
// getDate()/getFullYear() read the HOST machine's local zone, not a chosen zone.
const label = `${month}/${stored.getDate()}/${stored.getFullYear()}`;
// On a server set to America/New_York this prints "3/9/2024" (prev day, EDT);
// on a UTC server it prints "3/10/2024". Same input, different output.

Limitations: the output depends on process.env.TZ, there is no way to request an arbitrary zone without re-deriving offsets by hand, and getTimezoneOffset() reports the offset for now in the host zone with an inverted sign (positive means west of UTC), so it cannot answer "what was the offset in New York on that historical date."

Approach B: the Intl / Temporal solution

Split the job in two. Use Intl when you only need to display an instant in a chosen zone and locale, and use Temporal when you need to compute with calendar values.

import { Temporal } from '@js-temporal/polyfill';

const stored = new Date('2024-03-10T02:30:00Z'); // same absolute instant

// Display path: Intl resolves IANA rules at runtime; output is zone-explicit.
const fmt = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York', // never rely on the host zone
  year: 'numeric', month: '2-digit', day: '2-digit',
});
fmt.format(stored); // "03/09/2024" on EVERY machine, regardless of TZ

// Compute path: convert the instant to a zoned value, then read 1-based fields.
const zdt = Temporal.Instant
  .fromEpochMilliseconds(stored.getTime())
  .toZonedDateTimeISO('America/New_York');
zdt.month; // 3  — already 1-based, no "+1"
zdt.day;   // 9  — the New York calendar day, explicitly

Adding time is where the immutability and disambiguation options matter most:

// "30 days from now" that keeps the SAME wall-clock time, DST-safe.
const reminder = zdt.add({ days: 30 }); // calendar add: 09:30 local stays 09:30 local
// Adding clock hours instead adds ABSOLUTE time and may cross an offset change:
const inOneHour = zdt.add({ hours: 1 }); // absolute add: wall clock may jump 1->3 at spring-forward

// Constructing a wall-clock time that lands in a DST gap needs a policy:
const ambiguous = Temporal.ZonedDateTime.from(
  { timeZone: 'America/New_York', year: 2024, month: 3, day: 10, hour: 2, minute: 30 },
  { disambiguation: 'later' } // 02:30 doesn't exist on spring-forward; 'later' -> 03:30 EDT
);

disambiguation accepts 'earlier', 'later', 'compatible' (the default — push forward for gaps, pick the earlier instant for overlaps), or 'reject' (throw). For field overflow during arithmetic, add/with accept { overflow: 'constrain' } (clamp, the default) or { overflow: 'reject' } (throw). Choose 'reject' whenever a clamp would silently hide bad data.

Production implementation

A single utility that validates input, normalizes a legacy Date (or epoch ms) into a ZonedDateTime, and exposes both display and field access. Safe to run under SSR and serverless because the zone is always explicit.

import { Temporal } from '@js-temporal/polyfill';

// One cached formatter per locale+zone key — Intl.DateTimeFormat is expensive to build.
const fmtCache = new Map<string, Intl.DateTimeFormat>();
function formatter(locale: string, timeZone: string): Intl.DateTimeFormat {
  const key = `${locale}|${timeZone}`;
  let f = fmtCache.get(key);
  if (!f) {
    f = new Intl.DateTimeFormat(locale, {
      timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
    });
    fmtCache.set(key, f);
  }
  return f;
}

type DateLike = Date | number | string;

export function toZoned(input: DateLike, timeZone: string): Temporal.ZonedDateTime {
  // Validate the zone up front so a typo fails loudly, not silently as UTC.
  if (!timeZone) throw new TypeError('timeZone is required (pass an IANA id like "America/New_York")');
  let epochMs: number;
  if (input instanceof Date) {
    epochMs = input.getTime();
  } else if (typeof input === 'number') {
    epochMs = input; // assume milliseconds; backends sending seconds must multiply by 1000
  } else {
    const parsed = Date.parse(input); // tolerated only as an interop bridge for legacy strings
    if (Number.isNaN(parsed)) throw new RangeError(`Unparseable date string: ${input}`);
    epochMs = parsed;
  }
  if (!Number.isFinite(epochMs)) throw new RangeError('Invalid Date / epoch value');
  return Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(timeZone);
}

export function displayDate(input: DateLike, locale: string, timeZone: string): string {
  // Build the instant via Temporal for validation, then hand a Date to Intl for formatting.
  const zdt = toZoned(input, timeZone);
  return formatter(locale, timeZone).format(new Date(zdt.epochMilliseconds));
}

In serverless functions the host zone is frequently UTC but is not guaranteed across providers or regions, so the explicit timeZone argument is what keeps output deterministic. For broader migration sequencing across a whole codebase, see migrating from legacy date libraries, and for the type model behind ZonedDateTime start with getting started with the Temporal API.

Edge cases

Spring-forward gap (02:00–03:00 does not exist)

On 2024-03-10 US clocks jump from 01:59 to 03:00, so 02:30 is not a valid wall-clock time. new Date(2024, 2, 10, 2, 30) quietly normalizes to 03:30 with no signal. Temporal.ZonedDateTime.from(..., { disambiguation: 'reject' }) throws, letting you catch the impossible time instead of shipping it.

Fall-back overlap (01:00–02:00 happens twice)

On 2024-11-03 the 01:00–01:59 hour repeats. A bare wall-clock value is ambiguous. Temporal's default 'compatible' picks the earlier (pre-transition) instant; pass 'later' to select the second occurrence when, for example, scheduling the second 01:30 of the night.

Month-end rollover

new Date(2024, 0, 31) then setMonth(1) yields March 2 or 3, not "end of February," because day 31 overflows. plainDate.with({ month: 2 }) with the default overflow: 'constrain' clamps to Feb 29; 'reject' throws so you notice the impossible date.

Leap February 29

Temporal.PlainDate.from('2023-02-29', { overflow: 'reject' }) throws because 2023 is not a leap year, whereas new Date('2023-02-29') rolls forward to March 1 silently. Use 'reject' for user-entered dates and 'constrain' only when clamping is a deliberate product decision.

Gotchas & common pitfalls

Testing checklist

Scenario Input Expected
Spring-forward gap rejected from({…02:30…}, {disambiguation:'reject'}) on 2024-03-10 NY throws RangeError
Fall-back, second occurrence from({…01:30…}, {disambiguation:'later'}) on 2024-11-03 NY offset -05:00 (EST)
Month not 0-indexed Temporal.PlainDate.from('2024-03-10').month 3
Zone-explicit display is stable displayDate('2024-03-10T02:30:00Z','en-US','America/New_York') "03/09/2024"
Invalid Feb 29 rejected PlainDate.from('2023-02-29',{overflow:'reject'}) throws
Bad string fails loudly toZoned('not-a-date','UTC') throws RangeError

Run the suite across zones so host-zone leakage cannot hide:

# CI matrix: prove output is identical regardless of the runner's clock.
for z in UTC America/New_York Asia/Kolkata Pacific/Chatham; do TZ=$z npx jest; done

Frequently Asked Questions

Why does getMonth() return one less than the actual month?

Date follows the original ES1 design where months are 0-indexed (January is 0, December is 11). It is not a bug, just a legacy convention, which is why every legacy formatter adds + 1. Temporal uses 1-based months (plainDate.month is 1–12), eliminating the adjustment.

Should I replace Date everywhere at once?

No. Migrate at the call-site level: keep Date at serialization boundaries if you must, but route new display logic through Intl and new arithmetic through Temporal. Convert a legacy Date to Temporal with Temporal.Instant.fromEpochMilliseconds(d.getTime()) so the absolute instant is preserved exactly.

Is the Temporal API production-ready?

Temporal reached Stage 4 of TC39 and is part of ES2026, shipping natively in current Chrome and Firefox. For environments without native support, use @js-temporal/polyfill (roughly 18–22 KB gzipped) with a pinned version. Use plain Intl when you only need formatting and want zero polyfill weight.

How do I test date logic across DST boundaries?

Set the TZ environment variable across a CI matrix (TZ=America/New_York npx jest) and include the transition dates: 2024-03-10 (US spring-forward gap) and 2024-11-03 (US fall-back overlap), plus a leap-day case like 2024-02-29. Assert that zone-explicit output is identical on every runner.