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

Legacy Date accessors mapped to Temporal.PlainDate getMonth zero-indexed maps to month one-indexed; getDate maps to day; getDay zero-indexed Sunday maps to dayOfWeek one-indexed Monday. Legacy Date Temporal.PlainDate getMonth() 0 = Jan … 11 = Dec .month 1 = Jan … 12 = Dec getDate() day of month 1–31 .day day of month 1–31 getDay() 0 = Sun … 6 = Sat .dayOfWeek 1 = Mon … 7 = Sun Same names, consistent 1-indexing, no silent +1

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, dayOfWeek7).

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

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 06 with Sunday as 0. Temporal.PlainDate.dayOfWeek returns 17 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.