Replace getMonth() and getDate() With Temporal
The fix for Date.getMonth() off-by-one bugs and the getDate()/getDay() mix-up is to read Temporal.PlainDate.month (1-indexed), .day, and .dayOfWeek instead β every accessor returns the number you actually expect. This page is part of Legacy Date Methods vs Modern Alternatives.
Why this scenario is tricky
The legacy Date accessors carry two design decisions that have generated bugs for decades. First, getMonth() is zero-indexed: January is 0, December is 11. Code that prints or stores a month almost always wants the human number, so developers write getMonth() + 1 β and the moment someone forgets that + 1, October silently becomes September, or a date string like 2026-09-... is built for what the user picked as October. The bug is invisible in January-through-September logging windows and only surfaces when an off-by-one lands on a real boundary.
Second, getDate() and getDay() look almost identical but return completely different things. getDate() returns the day of the month (1β31). getDay() returns the day of the week as a zero-indexed integer where Sunday is 0 and Saturday is 6. A developer reaching for "the day" autocompletes to whichever comes first in the IDE, and a calendar cell that should read 15 instead renders 0β6. Worse, the two never throw β they both return valid-looking small integers, so the mistake ships.
Temporal.PlainDate removes both traps. month is 1-indexed (January is 1), day is the day of the month, and dayOfWeek is ISO-8601 1-indexed where Monday is 1 and Sunday is 7. The names are distinct, the indexing is consistent, and the values match what calendars, humans, and YYYY-MM-DD strings expect.
The diagram below maps each legacy accessor to its Temporal replacement and the indexing shift involved.
The legacy accessors at a glance
| Accessor | Returns | Range | Trap |
|---|---|---|---|
Date.getMonth() |
Month | 0β11 | Zero-indexed; needs + 1 for display |
Date.getDate() |
Day of month | 1β31 | Easily confused with getDay() |
Date.getDay() |
Day of week | 0 (Sun)β6 (Sat) | Zero-indexed, Sunday-first |
PlainDate.month |
Month | 1β12 | None β matches humans |
PlainDate.day |
Day of month | 1β31 | None |
PlainDate.dayOfWeek |
Day of week | 1 (Mon)β7 (Sun) | ISO-8601, Monday-first |
Minimal working solution
The shortest correct migration is to construct a Temporal.PlainDate and read its properties directly. Every value is already the number you want.
import { Temporal } from '@js-temporal/polyfill';
// October 6, 2026 β a Tuesday
const date = Temporal.PlainDate.from('2026-10-06');
console.log(date.month); // 10 β 1-indexed, no "+ 1" needed
console.log(date.day); // 6 β day of month, like getDate()
console.log(date.dayOfWeek); // 2 β 1=Monday β¦ 7=Sunday (Tuesday)
// Compare against the legacy traps:
const legacy = new Date(2026, 9, 6); // month arg is ALSO 0-indexed β 9 = October
console.log(legacy.getMonth()); // 9 β off by one vs human "10"
console.log(legacy.getDate()); // 6 β day of month
console.log(legacy.getDay()); // 2 β but here 2 = Tuesday with 0=Sunday
Note the subtle cross-check on the last block: getDay() and dayOfWeek both return 2 for this Tuesday, but they mean it under different schemes. With getDay(), 2 is Tuesday only because Sunday is 0 and Monday is 1. With dayOfWeek, 2 is Tuesday because Monday is 1. They agree here by coincidence of the offset β they disagree for Sunday (getDay() β 0, dayOfWeek β 7).
Full production version
Migrating off getMonth/getDate is less about a mechanical find-and-replace and more about fixing the two latent bugs those methods carry: the 0-based month and the implicit host zone. getMonth() returns 0 for January, so every read either adds one (and some site inevitably forgets) or feeds a 0-based value into logic expecting 1-based; Temporal.PlainDate.month is 1-based, matching human counting, so the correction disappears. And getDate()/getMonth() read in the host zone, so "the day" can differ between a UTC server and a user's browser near midnight; reading fields off a PlainDate you deliberately projected into a named zone makes the zone explicit and the value stable.
The production migration therefore proceeds field by field with intent: replace getFullYear/getMonth/getDate with year/month/day on a PlainDate obtained in an explicit zone, replace getDay() (0=Sunday) with dayOfWeek (1=Monday, ISO), and replace hour/minute reads similarly on a zoned value. Doing this converts a scattering of implicit-zone, 0-based reads into explicit, 1-based, zone-anchored ones, which is where the correctness gain comes from. Keep the ISO date as the canonical value and read whatever fields the UI needs from it, so every derived field agrees rather than being computed in a different zone.
In real migrations you receive a legacy Date (from a library, an API, or new Date(timestamp)) and must convert it without losing the host timezone's meaning. Convert through the user's zone, then read Temporal properties. Map dayOfWeek to a localized label with Intl rather than indexing a hand-written array.
import { Temporal } from '@js-temporal/polyfill';
interface CalendarFields {
year: number;
month: number; // 1-indexed
day: number; // day of month
dayOfWeek: number; // 1 = Monday β¦ 7 = Sunday (ISO-8601)
weekdayLabel: string;
monthLabel: string;
}
export function describeDate(
input: Date,
timeZone: string = Temporal.Now.timeZoneId(),
locale: string = 'en-US',
): CalendarFields {
if (!(input instanceof Date) || Number.isNaN(input.getTime())) {
// Reject Invalid Date up front so callers never read garbage fields
throw new TypeError('describeDate requires a valid Date');
}
// Anchor the absolute instant to the requested zone, then drop to wall-clock
const plain: Temporal.PlainDate = Temporal.Instant
.fromEpochMilliseconds(input.getTime())
.toZonedDateTimeISO(timeZone)
.toPlainDate();
// Intl reads from a Date; build one at local midnight for label formatting
const labelSource = new Date(Date.UTC(plain.year, plain.month - 1, plain.day));
const weekdayLabel = new Intl.DateTimeFormat(locale, {
weekday: 'long',
timeZone: 'UTC', // labelSource is UTC midnight β avoid host-zone drift
}).format(labelSource);
const monthLabel = new Intl.DateTimeFormat(locale, {
month: 'long',
timeZone: 'UTC',
}).format(labelSource);
return {
year: plain.year,
month: plain.month,
day: plain.day,
dayOfWeek: plain.dayOfWeek,
weekdayLabel,
monthLabel,
};
}
const fields = describeDate(new Date('2026-10-06T12:00:00Z'), 'UTC');
console.log(fields.month, fields.day, fields.dayOfWeek); // 10 6 2
console.log(fields.weekdayLabel, fields.monthLabel); // "Tuesday" "October"
Verification snippet
These assertions pin down the exact indexing differences β especially the Sunday case where getDay() and dayOfWeek diverge.
import { Temporal } from '@js-temporal/polyfill';
// 2026-10-06 is a Tuesday; 2026-10-11 is a Sunday.
const tue = Temporal.PlainDate.from('2026-10-06');
const sun = Temporal.PlainDate.from('2026-10-11');
console.assert(tue.month === 10, 'PlainDate.month is 1-indexed');
console.assert(new Date(2026, 9, 6).getMonth() === 9, 'getMonth() is 0-indexed');
// Tuesday: both schemes happen to return 2
console.assert(tue.dayOfWeek === 2, 'Temporal Tuesday = 2 (Mon=1)');
console.assert(new Date(2026, 9, 6).getDay() === 2, 'legacy Tuesday = 2 (Sun=0)');
// Sunday: the schemes DISAGREE β this is the trap getDay() hides
console.assert(sun.dayOfWeek === 7, 'Temporal Sunday = 7');
console.assert(new Date(2026, 9, 11).getDay() === 0, 'legacy Sunday = 0');
console.log('All indexing assertions passed');
Common pitfalls
The first pitfall is a literal translation that preserves the bugs β mapping getMonth() to something still 0-based, or reading fields in the host zone β instead of taking the migration as the moment to make the month 1-based and the zone explicit. The second is the weekday numbering change: getDay() is 0=Sunday but dayOfWeek is 1=Monday, so any weekday logic must be re-based, not copied. The third is mixing migrated and unmigrated reads on the same value, so some months are 0-based and others 1-based, which is worse than either alone.
A fourth pitfall is reading year/month/day in one zone but a related field like dayOfWeek or weekOfYear in another, producing an internally inconsistent set; read them all from one PlainDate in one zone. A fifth is forgetting that a new Date() you are migrating from carries a time and a zone, so a "date-only" read built on it can have a time component leak in β starting from PlainDate removes that risk. Treat the replacement as a correctness upgrade, not a syntax swap, and the latent off-by-one and off-by-a-day bugs go away with it.
-
Forgetting
+ 1aftergetMonth(). Wrong:`2026-${d.getMonth()}-01`produces month9for October. Right: readTemporal.PlainDate.from(...).month, which is already10. If you must stay onDate, never interpolategetMonth()directly. -
Calling
getDate()when you meantgetDay()(or vice versa). Wrong:cell.textContent = d.getDay()to fill a calendar cell renders0β6. Right:cell.textContent = String(plainDate.day)renders the day of month. The distinct Temporal names (dayvsdayOfWeek) make the mistake hard to make. -
Assuming Sunday is the first weekday. Wrong: indexing a
['Mon','Tue',...]array withgetDay()shifts every label by one becausegetDay()puts Sunday at0. Right:dayOfWeekis1β7Monday-first per ISO-8601; for Sunday-first UIs, remember the offset or format withIntl. -
Reusing the
Dateconstructor's 0-indexed month while building a Temporal value. Wrong:Temporal.PlainDate.from({ year: 2026, month: new Date().getMonth(), day: 1 })feeds a 0-indexed month into a 1-indexed API. Right: usegetMonth() + 1, or skipDateentirely and useTemporal.Now.plainDateISO().
Frequently Asked Questions
Why is Date.getMonth() zero-indexed at all?
It mirrors a 1995-era C/Java convention where months were array indices. Temporal deliberately broke from this: PlainDate.month is 1-indexed so the value matches YYYY-MM-DD strings and human expectations, eliminating the + 1 ritual.
What is the difference between getDay() and Temporal's dayOfWeek?
getDay() returns 0β6 with Sunday as 0. Temporal.PlainDate.dayOfWeek returns 1β7 following ISO-8601, where Monday is 1 and Sunday is 7. They only coincide for Monday through Saturday by offset; Sunday differs (0 vs 7).
How do I get a localized weekday or month name from a PlainDate?
Build a Date at UTC midnight from the PlainDate fields and pass it to Intl.DateTimeFormat with { weekday: 'long', timeZone: 'UTC' } (or month: 'long'). Forcing timeZone: 'UTC' prevents the host zone from shifting the rendered day.
How do I replace getMonth() and getDate() with Temporal?
Read year, month, and day off a Temporal.PlainDate obtained in an explicit zone. Temporal's month is 1-based (January is 1), so unlike getMonth() there is no +1 correction, and reading from a deliberately-zoned PlainDate makes the value stable rather than dependent on the host zone. Replace getDay() with dayOfWeek, noting it is 1=Monday (ISO) rather than 0=Sunday.
Why is migrating off getMonth() a correctness upgrade, not just a rename?
Because getMonth() carries two latent bugs: it is 0-based, so reads need a +1 that some site forgets, and it reads in the host zone, so 'the month' can differ between a UTC server and a user's browser near midnight. Temporal's 1-based month and explicit-zone reads fix both. Treating the migration as a mechanical rename preserves the bugs; taking it as the moment to make months 1-based and zones explicit removes them.
What changes about weekday numbers when moving to Temporal?
Legacy getDay() returns 0 for Sunday through 6 for Saturday, while Temporal's dayOfWeek returns 1 for Monday through 7 for Sunday (ISO 8601). Any weekday logic must be re-based to the new numbering rather than copied verbatim, or every weekday comparison is shifted. Standardize on the ISO 1β7 scheme and convert to display names at the UI edge.