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.

Legacy β†’ modernEach legacy accessor and its replacementLegacy β†’ moderngetMonth() (0–11)PlainDate.month (1–12)setHours() (mutates)with({hour}) (immutable)toLocaleString()Intl.DateTimeFormatDate.parse()Temporal.*.from

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."

Legacy DateZero-indexed, mutable, host-zonedLegacyModerngetMonth() + 1setHours mutateshost zone gettersfootgun-pronePlainDate.monthwith() immutableexplicit timeZonepredictable

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.

Modern replacementRead fields off Temporal, format with IntlModern replacementInstant / zoneTemporalfieldsIntl.format

Production implementation

The production value of replacing legacy Date methods is not stylistic; it is that the legacy accessors carry two latent bugs that the modern equivalents remove. The getter family (getFullYear/getMonth/getDate/getDay/getHours) reads in the host zone, so the same instant yields different fields on a UTC server than in a user's browser, and getMonth/getDay use surprising numbering (0-based month, 0=Sunday). The set* mutators change the object in place, enabling aliasing bugs. Replacing these with reads off a PlainDate/ZonedDateTime obtained in an explicit zone β€” using 1-based month and ISO dayOfWeek β€” makes every field zone-anchored, correctly numbered, and immutable.

So the migration is done method by method with intent, and the payoff is measured in bugs removed rather than lines changed. Each legacy read becomes an explicit-zone read; each mutation becomes a value-returning transformation; each format goes through Intl with an explicit timeZone. Doing this behind a boundary lets the app keep working during the migration, and keeping the ISO date as the canonical value means every derived field is read from one source in one zone, so they never disagree. The result is code whose date behavior is explicit and reproducible instead of implicitly tied to the host environment.

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.

Migrate accessorsConvert at the boundary, use immutable fields inwardMigrate accessorslegacy DatetoTemporalInstant()PlainDatefieldsIntl display

Edge cases

The numbering and zone edges are where literal translations of legacy code go wrong. getMonth() is 0-based and month is 1-based, so a copied formula shifts by a month; getDay() is 0=Sunday and dayOfWeek is 1=Monday, so weekday logic must be re-based. Reads near midnight are the zone edge: getDate() in the host zone can report a different day than the user's, so date-only logic must read from a value projected into the user's zone. And because new Date() carries a time and a zone, "date-only" code built on it can have a time component leak in β€” starting from PlainDate removes that.

The mutation edge is subtler. date.setMonth(...) returns a timestamp and mutates the object, so code that relied on the mutation (or accidentally shared the object) behaves differently once ported to immutable Temporal values that return new instances; audit for ignored return values, which with Temporal silently do nothing. Formatting edges round it out: toLocaleString without options and an explicit zone is non-deterministic across engines and locales, so replace it with Intl.DateTimeFormat configured explicitly. Each edge is an instance of the same theme β€” the legacy method left something implicit (zone, numbering, mutation, format) that the modern equivalent makes explicit.

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.

Legacy accessor edge casesWhere zero-indexing and mutation biteLegacy accessor edge casesMonth index0=Janoff-by-oneMutationsetDate shared refclone firstday vs dategetDay=weekdaygetDate=day

Gotchas & common pitfalls

Legacy method pitfallsIndexing and mutationWrongRightgetMonth() as 1–12every month offPlainDate.month 1–12calendar-alignedsetHours on shared Datecorrupts refswith() returns a copyimmutable

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

Legacy→modern test matrixReplacements match expected valuesAssertions that prove the edge caseMarch .month3with({hour:9})new valueIntl formatstable stringno mutationoriginal intact

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.

Why replace legacy Date methods with Temporal and Intl?

Because the legacy accessors carry latent bugs: the getters read in the host zone (so fields differ between a server and a user's browser), getMonth is 0-based and getDay is 0=Sunday, and the setters mutate in place, enabling aliasing bugs. Reading off a PlainDate or ZonedDateTime in an explicit zone gives 1-based months, ISO weekdays, immutability, and zone-anchored fields β€” removing the bugs rather than restyling the code.

What changes when migrating getMonth() and getDay() to Temporal?

getMonth() is 0-based (January is 0) while Temporal's month is 1-based, so a copied formula shifts by a month and must be re-based. getDay() is 0=Sunday while Temporal's dayOfWeek is 1=Monday (ISO), so weekday logic must also be re-based. Treat the migration as a correctness upgrade: make months 1-based, weekdays ISO-numbered, and reads zone-explicit, rather than a mechanical rename that preserves the old numbering.

Why is toLocaleString discouraged for production formatting?

Because without explicit options and an explicit timeZone its output varies across engines and locales and may use the host zone, so the same instant renders inconsistently and the result is not stable enough to parse back or snapshot-test. Replace it with Intl.DateTimeFormat configured with explicit fields and a timeZone for deterministic, cross-engine output.