JAVASCRIPT / DATES, TIME, AND INTERNATIONALIZATION
Temporal PlainDate for calendar dates
Use Temporal.PlainDate to model calendar dates with no time or zone, do month-safe arithmetic, and compare them without coercion bugs.
What you will learn
- Create dates with Temporal.PlainDate.from() from ISO strings or {year, month, day}
- Use overflow: 'reject' to refuse impossible bags like {month: 2, day: 30}
- Compare with equals() and PlainDate.compare(); === is always false and < throws
- Measure gaps with until()/since() and set largestUnit to get months plus days
Understanding Temporal PlainDate for calendar dates
Temporal.PlainDate holds exactly three numbers plus a calendar: year, month, and day. It has no hour, no UTC offset, and no time zone, which makes it the right type for a birthday, an invoice date, or a hotel check-in day, values that mean the same thing in Tokyo and in Lisbon. Every method returns a new PlainDate instead of mutating the receiver, so calling add on a date leaves the original variable pointing at the old day. Derived facts like daysInMonth, dayOfWeek, dayOfYear, and inLeapYear come from the calendar, so you never hand-write the 4/100/400 leap year rule.
The mental model is a page on a wall calendar, not a point on a timeline. Because no zone is attached, a PlainDate never shifts for a reader in another region and never loses an hour to a daylight saving transition; it becomes an instant only when you deliberately attach a time zone and a wall-clock time. That also means its arithmetic is calendar arithmetic: adding one month moves the month field and then repairs the day if the result does not exist, which is why January 31 plus one month is February 28 rather than March 3. That repair discards information, so add and subtract do not always round-trip.
Construction and comparison are deliberately strict. from() accepts an ISO string or a property bag, and for bags the overflow option chooses between clamping the day (constrain, the default) and throwing (reject), which makes reject the right setting for anything a human typed. For comparison, valueOf throws on purpose, so a < b raises a TypeError rather than quietly comparing coerced junk, while a === b is false for two separate objects that name the same day. Use equals for a yes/no check and Temporal.PlainDate.compare for ordering, including directly as a sort callback.
const launch = Temporal.PlainDate.from('2026-01-31');
console.log(launch.toString(), launch.dayOfWeek, launch.daysInMonth, launch.inLeapYear);
const nextMonth = launch.add({ months: 1 });
console.log(nextMonth.toString(), nextMonth.daysInMonth);
console.log(nextMonth.subtract({ months: 1 }).toString());
const sameDay = Temporal.PlainDate.from({ year: 2026, month: 1, day: 31 });
console.log(launch.equals(sameDay), launch === sameDay);
console.log(Temporal.PlainDate.compare(launch, nextMonth));A PlainDate is a position on a calendar rather than a moment in time, so it never shifts with time zones and its arithmetic obeys the real lengths of months.
Worked examples
Rejecting dates that do not exist
Shows how overflow turns an impossible day into either a clamped date or a RangeError.
function safeFrom(bag) {
try {
return Temporal.PlainDate.from(bag, { overflow: 'reject' }).toString();
} catch (err) {
return err.name + ' for day ' + bag.day;
}
}
console.log(safeFrom({ year: 2026, month: 2, day: 30 }));
console.log(safeFrom({ year: 2028, month: 2, day: 29 }));
console.log(Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 }).toString());Example explained
Line 1overflow: 'reject' converts an impossible field combination into a RangeError instead of a nearby valid date.
Line 22028-02-29 is accepted because the ISO calendar knows 2028 is a leap year; the identical bag with year 2026 is rejected.
Line 3The final line uses the default overflow: 'constrain', which silently clamps day 30 to February 28 and returns a date nobody entered.
Measuring the gap between two dates
Shows that until() returns a Duration whose shape depends on largestUnit and on operand order.
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-12-25');
const gap = start.until(end);
console.log(gap.toString(), gap.days);
const calendarGap = start.until(end, { largestUnit: 'month' });
console.log(calendarGap.toString(), calendarGap.months, calendarGap.days);
console.log(end.until(start).toString());Example explained
Line 1For PlainDate the default largestUnit is 'day', so the entire gap arrives as a single day count.
Line 2largestUnit: 'month' re-expresses the same gap as 11 months and 24 days, which is only meaningful relative to the start date.
Line 3Swapping the operands flips the sign to -P358D; end.since(start) is the positive form of the same gap.
Walking forward to a weekday
Uses dayOfWeek and add() to step to the next three Fridays without any manual day counting.
function nextFriday(date) {
const shift = (5 - date.dayOfWeek + 7) % 7 || 7;
return date.add({ days: shift });
}
let day = Temporal.PlainDate.from('2026-09-03');
for (let i = 0; i < 3; i++) {
day = nextFriday(day);
console.log(day.toString(), day.dayOfWeek, 'week', day.weekOfYear);
}Example explained
Line 1In the ISO calendar dayOfWeek runs 1 for Monday to 7 for Sunday, so Friday is 5 and the modulo gives the days to skip.
Line 2The || 7 makes a Friday input jump a whole week instead of returning itself, because the modulo would otherwise be 0.
Line 3Reassigning day = nextFriday(day) is mandatory: add returns a new PlainDate and never changes the one it was called on.
Line 4weekOfYear is the ISO week number, which advances by exactly one on each step.
Important notes
Months are 1-based here: month 1 is January, unlike the 0-based legacy Date getMonth.
overflow only affects property bags; Temporal.PlainDate.from('2026-02-30') throws a RangeError either way. A PlainDate also has no epoch value, so attach a zone and a time with toZonedDateTime before you can get an instant.
Common mistakes
Comparing with === or <: === returns false for two distinct objects that name the same day, so an 'unchanged?' check quietly fails, while < throws TypeError because valueOf is intentionally disabled.
Ignoring the return value, as in date.add({ days: 1 }) on its own: PlainDate is immutable, so the variable still holds the old day and loops never advance.
Assuming month arithmetic round-trips: 2026-01-31 plus one month is 2026-02-28, and subtracting one month from that gives 2026-01-28, so a billing loop that repeatedly adds a month drifts off the 31st. Keep the original anchor date and add { months: n } instead.
Try it yourself
Change, predict, then run
In a console where Temporal is available, write lastDayOfMonth(isoString) that returns the final day of that month as a string, using daysInMonth together with with({ day }). Check that '2026-02-15' gives 2026-02-28 and '2028-02-15' gives 2028-02-29.
Open the JavaScript workspaceCheck your understanding
What does Temporal.PlainDate.from('2026-03-31').add({ months: 1 }).subtract({ months: 1 }) produce, and why?
- 2026-03-30, because adding a month clamps to April 30 and day 30 then exists in March
- 2026-03-31, because Temporal arithmetic is designed to be reversible
- A RangeError, because April 31 does not exist
- 2026-05-01, because the leftover day rolls into the following month
Show answer
Adding a month lands on April 31, which does not exist, so the default constrain behaviour clamps it to 2026-04-30; subtracting a month from April 30 gives March 30, since day 30 is valid in March and nothing needs repairing. Reversibility is tempting to assume from ordinary number arithmetic, but the clamp throws away the original day 31, and a RangeError would only appear if you passed { overflow: 'reject' }.