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