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:
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
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.
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.
Gotchas & common pitfalls
- Off-by-one month β
getMonth()is 0-indexed. Fix: readplainDate.month(1β12), or audit every+ 1you removed. getYear()returns year β 1900 β never use it. Fix:getFullYear()for legacy code,plainDate.yearfor new code.- Inverted offset sign β
getTimezoneOffset()returns positive for zones west of UTC and reflects only the current moment in the host zone. Fix: readzdt.offseton a zoned value for the actual date. - Implicit local parsing β
new Date('2024-03-10')(date-only) parses as UTC, butnew Date('2024-03-10T00:00')parses as host-local. Fix: parse withTemporal.PlainDate.from/PlainDateTime.fromand attach a zone explicitly. - Missing
timeZonein formatting βtoLocaleString()withouttimeZonefollows the host zone and causes SSR hydration mismatches. Fix: always passtimeZoneand cache theIntl.DateTimeFormat. - Mutating shared
DateβsetHours()mutates in place, corrupting any other reference and triggering false-positive change detection in React/Vue/Angular. Fix: Temporal values are immutable;.with(...)returns a copy.
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.
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.
Related
- Intl API & Legacy Date Patterns β parent overview of legacy interop and the Intl family.
- Replace getMonth and getDate with Temporal β focused recipe for the most common accessor swap.
- Migrating from legacy date libraries β sequencing a Moment/date-fns retirement.
- Getting started with the Temporal API β the type model behind
Instant,PlainDate, andZonedDateTime. - Safe timezone detection in browsers β choosing the IANA zone you pass to these utilities.