EdgeCases Logo
Feb 2026
JavaScript
Expert
8 min read

JavaScript Temporal API Gotchas: What Breaks When Migrating from Date

JavaScript's Temporal API replaces Date, but brings new complexity. DST ambiguity, Plain vs Zoned types, and strict parsing will trip up migrating teams.

javascript
temporal
date-time
timezone
dst
migration

The Temporal API is JavaScript's replacement for Date—and it's landing in browsers now. It fixes decades of pain: mutable dates, timezone chaos, month indexing from zero. But Temporal introduces its own complexity. Teams migrating from Date, Moment, or Day.js will hit these edge cases. Here's what the docs bury.

Plain vs Zoned: The Core Mental Model

Temporal splits dates into two families that don't mix without explicit conversion:

// "Plain" types: No timezone, just calendar values
Temporal.PlainDate      // 2026-02-28
Temporal.PlainTime      // 14:30:00
Temporal.PlainDateTime  // 2026-02-28T14:30:00

// "Exact" types: Specific moment in time
Temporal.Instant        // 2026-02-28T13:30:00Z (always UTC)
Temporal.ZonedDateTime  // 2026-02-28T14:30:00+01:00[Europe/Madrid]

The gotcha: PlainDateTime is NOT a moment in time. "2026-02-28T14:30" doesn't tell you when something happens—it could be any of 24+ different instants depending on timezone.

The DST Ambiguity Trap

When clocks "fall back" in autumn, there's an hour that occurs twice. When clocks "spring forward," there's an hour that doesn't exist:

// March 31, 2024: Europe/Madrid springs forward at 2:00 AM
// 2:30 AM doesn't exist—clocks jump from 1:59 to 3:00

const dt = Temporal.PlainDateTime.from('2024-03-31T02:30:00');

// ❌ This throws by default!
dt.toZonedDateTime('Europe/Madrid');
// RangeError: 2024-03-31T02:30 doesn't exist in Europe/Madrid

// ✅ Handle the ambiguity explicitly
dt.toZonedDateTime('Europe/Madrid', { disambiguation: 'earlier' });
// Resolves to 01:30 (before the gap)

dt.toZonedDateTime('Europe/Madrid', { disambiguation: 'later' });
// Resolves to 03:30 (after the gap)

dt.toZonedDateTime('Europe/Madrid', { disambiguation: 'compatible' });
// Same as 'later' for gaps, 'earlier' for overlaps (Date behavior)

The disambiguation option is required knowledge:

  • reject (default) — Throws on ambiguous times
  • compatible — Matches legacy Date behavior
  • earlier — Pick the earlier of two possible times
  • later — Pick the later of two possible times

Arithmetic on the Wrong Type

Adding "1 day" means different things depending on type:

// PlainDateTime: Always adds exactly 24 hours worth of calendar time
const plain = Temporal.PlainDateTime.from('2024-03-30T12:00');
plain.add({ days: 1 }).toString();
// "2024-03-31T12:00" — ignores DST

// ZonedDateTime: Respects DST transitions
const zoned = Temporal.ZonedDateTime.from('2024-03-30T12:00[Europe/Madrid]');
zoned.add({ days: 1 }).toString();
// "2024-03-31T12:00+02:00[Europe/Madrid]"

// BUT: The actual elapsed time is 23 hours, not 24!
// Because clocks jumped forward at 2 AM

// If you wanted exactly 24 elapsed hours:
zoned.add({ hours: 24 }).toString();
// "2024-03-31T13:00+02:00[Europe/Madrid]" — 1 PM, not noon!

The distinction matters for scheduling vs time tracking. "Daily standup at 9 AM" should use days. "24-hour SLA timer" should use hours.

Instant Has No Calendar

Temporal.Instant is pure UTC milliseconds (or nanoseconds). It has no concept of "year" or "month":

const instant = Temporal.Now.instant();

// ❌ These don't exist on Instant
instant.year;        // undefined
instant.month;       // undefined
instant.dayOfWeek;   // undefined

// ✅ Convert to ZonedDateTime or PlainDateTime first
const zoned = instant.toZonedDateTimeISO('Europe/Madrid');
zoned.year;          // 2026
zoned.month;         // 2
zoned.dayOfWeek;     // 6 (Saturday)

If you're checking "is it Monday?" you must convert to a timezone-aware type first. The same instant is Monday in Tokyo but Sunday in New York.

Duration Balancing Surprises

Temporal durations don't auto-balance by default:

const dur = Temporal.Duration.from({ hours: 25 });
dur.toString(); // "PT25H" — not "P1DT1H"

// Balancing requires explicit call
dur.round({ largestUnit: 'day' }).toString();
// "P1DT1H" — now balanced

// But "balance" is relative to a reference
dur.round({
  largestUnit: 'day',
  relativeTo: '2024-03-30T00:00[Europe/Madrid]'
});
// May give different results around DST transitions!

"25 hours" is not always "1 day and 1 hour" during DST changes. Temporal forces you to be explicit about this.

Calendar Systems Aren't Just Gregorian

Temporal supports non-Gregorian calendars natively, which means date arithmetic can behave unexpectedly if you're not explicit:

// Hebrew calendar month can have 29 or 30 days
const hebrewDate = Temporal.PlainDate.from({
  year: 5786,
  month: 6,
  day: 30,
  calendar: 'hebrew'
});

// Adding a month behaves differently than Gregorian
hebrewDate.add({ months: 1 });

// Always specify calendar when parsing external data
Temporal.PlainDate.from('2026-02-28'); // Assumes ISO calendar
Temporal.PlainDate.from('2026-02-28[u-ca=gregorian]'); // Explicit

Most apps use ISO-8601 (Gregorian), but if you accept user input or integrate with systems that use other calendars (Hebrew, Islamic, Japanese), be explicit.

Comparison Requires Same Types

You can't directly compare Plain and Zoned types:

const plain = Temporal.PlainDateTime.from('2026-02-28T12:00');
const zoned = Temporal.ZonedDateTime.from('2026-02-28T12:00[UTC]');

// ❌ This doesn't work as expected
Temporal.PlainDateTime.compare(plain, zoned);
// Type error or unexpected behavior

// ✅ Convert to same type first
const plainFromZoned = zoned.toPlainDateTime();
Temporal.PlainDateTime.compare(plain, plainFromZoned); // 0

// Or compare instants
const instant1 = zoned.toInstant();
const instant2 = plain.toZonedDateTime('UTC').toInstant();
Temporal.Instant.compare(instant1, instant2); // 0

Parsing Is Strict

Unlike new Date(), Temporal parsing is strict:

// Date accepts garbage and tries to parse it
new Date('2026-2-28');     // Works (implementation-dependent)
new Date('Feb 28 2026');   // Works
new Date('garbage');       // "Invalid Date" (not an error!)

// Temporal throws on invalid input
Temporal.PlainDate.from('2026-2-28');
// RangeError: Invalid ISO 8601 string

Temporal.PlainDate.from('Feb 28 2026');
// RangeError: Invalid ISO 8601 string

// Valid formats:
Temporal.PlainDate.from('2026-02-28');        // ISO 8601
Temporal.PlainDate.from({ year: 2026, month: 2, day: 28 }); // Object

This is intentional—Temporal rejects ambiguous inputs rather than guessing. Use a parsing library for human-friendly formats.

Immutability Is Total

All Temporal objects are immutable. Every operation returns a new instance:

const date = Temporal.PlainDate.from('2026-02-28');

// ❌ This doesn't mutate 'date'
date.add({ days: 1 });
console.log(date.toString()); // Still "2026-02-28"

// ✅ Capture the return value
const tomorrow = date.add({ days: 1 });
console.log(tomorrow.toString()); // "2026-03-01"

Coming from mutable Date, this is a common source of bugs.

Browser Support (2026)

Temporal is shipping progressively:

  • Chrome/Edge: 130+ (shipped)
  • Firefox: 135+ (shipped)
  • Safari: 18.4+ (shipped)
  • Node.js: 22.8+ with --experimental-temporal

For older environments, use the temporal-polyfill package. Be aware the polyfill is ~70KB minified—consider code-splitting if only some pages need it.

Key Takeaways

  • Plain types have no timezone; Zoned/Instant represent real moments
  • DST gaps and overlaps require explicit disambiguation handling
  • add({ days: 1 }) differs from add({ hours: 24 }) around DST
  • Instant has no calendar properties—convert to ZonedDateTime first
  • Duration balancing is explicit and DST-aware
  • Parsing is strict ISO 8601; no fuzzy date parsing
  • All operations return new instances—capture return values

Advertisement

Related Insights

Explore related edge cases and patterns

Browser APIs
Surface
Web Workers Structured Clone: What Can't Cross the Boundary
6 min
JavaScript
Deep
structuredClone(): The Deep Copy That Isn't Always Deep
7 min

Advertisement