JavaScript Temporal API: Modern Date/Time Handling

JavaScript's `Date` object has been a source of frustration since the language's inception. It's mutable, making it easy to accidentally modify dates passed between functions. Its timezone handling...

Key Insights

  • The JavaScript Date object is fundamentally broken: it’s mutable, timezone-confused, and has inconsistent month indexing that has plagued developers for decades
  • Temporal introduces separate types for different use cases (PlainDate, ZonedDateTime, Instant) rather than one confused object trying to do everything
  • All Temporal objects are immutable and operations return new instances, eliminating an entire class of bugs caused by accidental mutation

The Problem with Date

JavaScript’s Date object has been a source of frustration since the language’s inception. It’s mutable, making it easy to accidentally modify dates passed between functions. Its timezone handling is implicit and confusing—sometimes it operates in UTC, sometimes in local time, and the behavior isn’t always predictable. Month indexing starts at 0 while days start at 1, an inconsistency that catches even experienced developers off guard.

Consider this typical Date confusion:

// Date quirks
const date = new Date('2024-01-15');
console.log(date.getMonth()); // 0 (January, but why?)
date.setMonth(13); // Overflows to February of next year
console.log(date); // Now it's 2025-02-15

// Timezone confusion
const parsed = new Date('2024-01-15');
console.log(parsed.toString()); // Local timezone interpretation
console.log(parsed.toISOString()); // UTC, but might not be what you expected

// Mutation problems
function addDay(date) {
  date.setDate(date.getDate() + 1);
  return date; // Mutates the original!
}

The Temporal API, currently a Stage 3 TC39 proposal, solves these problems with a complete redesign. Instead of one confused object, Temporal provides specialized types for different use cases, all immutable by default.

// Temporal clarity
const date = Temporal.PlainDate.from('2024-01-15');
console.log(date.month); // 1 (January, intuitive!)

// Adding months handles edge cases correctly
const endOfJan = Temporal.PlainDate.from('2024-01-31');
const endOfFeb = endOfJan.add({ months: 1 });
console.log(endOfFeb.toString()); // 2024-02-29 (leap year aware)

// Immutability by default
function addDay(date) {
  return date.add({ days: 1 }); // Returns new instance
}

Core Temporal Types

Temporal’s philosophy is simple: use the right type for your use case. This eliminates ambiguity and makes your intentions explicit.

Temporal.PlainDate represents a calendar date without time or timezone. Use it for birthdays, holidays, or any date where the time doesn’t matter:

const birthday = Temporal.PlainDate.from('1990-05-15');
const today = Temporal.Now.plainDateISO();
const age = today.since(birthday, { largestUnit: 'years' }).years;

Temporal.PlainTime represents wall-clock time without a date. Perfect for recurring events like “office hours are 9 AM to 5 PM”:

const officeStart = Temporal.PlainTime.from('09:00:00');
const officeEnd = Temporal.PlainTime.from('17:00:00');
const now = Temporal.Now.plainTimeISO();

if (Temporal.PlainTime.compare(now, officeStart) >= 0 && 
    Temporal.PlainTime.compare(now, officeEnd) < 0) {
  console.log('Office is open');
}

Temporal.PlainDateTime combines date and time but without timezone information. Use it when you need both but timezone doesn’t matter, like scheduling a local event:

const localMeeting = Temporal.PlainDateTime.from('2024-03-15T14:30:00');
const inTwoHours = localMeeting.add({ hours: 2 });

Temporal.ZonedDateTime includes full timezone information. This is what you want for coordinating across timezones or storing precise moments that need to respect local time rules:

const nycMeeting = Temporal.ZonedDateTime.from({
  timeZone: 'America/New_York',
  year: 2024,
  month: 3,
  day: 15,
  hour: 14,
  minute: 30
});

Temporal.Instant represents an absolute point on the timeline, independent of calendars or timezones. Use it for timestamps, measuring durations, or when you need UTC:

const now = Temporal.Now.instant();
const timestamp = now.epochMilliseconds; // Unix timestamp

Working with Timezones

Temporal handles timezones correctly by integrating with the IANA timezone database. Converting between zones is explicit and handles DST transitions properly:

// Schedule a meeting in New York and see when it is in Tokyo
const nycMeeting = Temporal.ZonedDateTime.from({
  timeZone: 'America/New_York',
  year: 2024,
  month: 3,
  day: 10, // During DST transition!
  hour: 14,
  minute: 0
});

const tokyoTime = nycMeeting.withTimeZone('Asia/Tokyo');
console.log(tokyoTime.toString());
// 2024-03-11T04:00:00+09:00[Asia/Tokyo]

// Handle DST transitions correctly
const beforeDST = Temporal.ZonedDateTime.from(
  '2024-03-10T01:30:00-05:00[America/New_York]'
);
const afterDST = beforeDST.add({ hours: 2 });
// Correctly accounts for the "spring forward" at 2 AM
console.log(afterDST.toString());
// 2024-03-10T04:30:00-04:00[America/New_York] (note offset change)

For applications that coordinate across timezones, convert to Instant for storage and comparison, then convert to ZonedDateTime for display:

// Store as Instant (UTC)
const instant = nycMeeting.toInstant();
const stored = instant.toString(); // ISO 8601 UTC string

// Later, display in user's timezone
const retrieved = Temporal.Instant.from(stored);
const userTime = retrieved.toZonedDateTimeISO('Europe/London');

Date Arithmetic and Comparisons

Temporal’s arithmetic operations are intuitive and handle edge cases correctly. All operations return new instances, preventing mutation bugs:

// Calculate business days between dates
function businessDaysBetween(start, end) {
  let current = start;
  let count = 0;
  
  while (Temporal.PlainDate.compare(current, end) < 0) {
    const dayOfWeek = current.dayOfWeek;
    if (dayOfWeek !== 6 && dayOfWeek !== 7) { // Not Sat or Sun
      count++;
    }
    current = current.add({ days: 1 });
  }
  
  return count;
}

const start = Temporal.PlainDate.from('2024-01-15');
const end = Temporal.PlainDate.from('2024-01-31');
console.log(businessDaysBetween(start, end)); // 12 business days

Adding months correctly handles month-end edge cases:

// Date would give weird results here
const jan31 = Temporal.PlainDate.from('2024-01-31');
const feb = jan31.add({ months: 1 });
console.log(feb.toString()); // 2024-02-29 (constrained to valid date)

// You can control overflow behavior
const march = jan31.add({ months: 2 }, { overflow: 'reject' });
// Throws if result would be invalid

Duration calculations are explicit and readable:

const start = Temporal.PlainDateTime.from('2024-01-15T09:00:00');
const end = Temporal.PlainDateTime.from('2024-03-20T17:30:00');

const duration = start.until(end, { 
  largestUnit: 'months',
  smallestUnit: 'minutes'
});

console.log(duration.toString()); // P2M5DT8H30M
console.log(`${duration.months} months, ${duration.days} days, ${duration.hours} hours`);

Formatting and Parsing

Temporal integrates seamlessly with Intl.DateTimeFormat for locale-aware formatting:

const date = Temporal.ZonedDateTime.from(
  '2024-03-15T14:30:00+09:00[Asia/Tokyo]'
);

// Format for different locales
const usFormat = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'full',
  timeStyle: 'long',
  timeZone: 'Asia/Tokyo'
});
console.log(usFormat.format(date));
// Friday, March 15, 2024 at 2:30:00 PM JST

const jpFormat = new Intl.DateTimeFormat('ja-JP', {
  dateStyle: 'full',
  timeStyle: 'long',
  timeZone: 'Asia/Tokyo'
});
console.log(jpFormat.format(date));
// 2024年3月15日金曜日 14:30:00 日本標準時

Parsing is strict and unambiguous:

// ISO 8601 strings parse reliably
const plainDate = Temporal.PlainDate.from('2024-03-15');
const zonedDate = Temporal.ZonedDateTime.from(
  '2024-03-15T14:30:00+09:00[Asia/Tokyo]'
);

// Construct from components for non-ISO sources
const fromObject = Temporal.PlainDate.from({
  year: 2024,
  month: 3,
  day: 15
});

Migration Strategy and Browser Support

Temporal is currently Stage 3 but not yet in browsers. Use the official polyfill for production:

npm install @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill';

// Use Temporal normally
const date = Temporal.Now.plainDateISO();

For gradual migration, wrap Temporal with fallback logic:

// date-utils.js
import { Temporal } from '@js-temporal/polyfill';

export function createDate(isoString) {
  try {
    return Temporal.PlainDate.from(isoString);
  } catch (e) {
    // Fallback for edge cases
    return new Date(isoString);
  }
}

export function addDays(date, days) {
  if (date instanceof Temporal.PlainDate) {
    return date.add({ days });
  }
  // Fallback for Date objects
  const result = new Date(date);
  result.setDate(result.getDate() + days);
  return result;
}

Start using Temporal for new features while gradually migrating existing code. Focus on high-pain areas first: timezone handling, date arithmetic, and anywhere you’re using libraries like Moment.js or date-fns.

Best Practices and Conclusion

Use PlainDate for dates without time (birthdays, deadlines). Use ZonedDateTime when coordinating across timezones (meetings, events). Use Instant for timestamps and storage. Avoid PlainDateTime unless you specifically need date+time without timezone semantics.

Always be explicit about timezones. If you need to store a moment in time, store it as an Instant (UTC) and convert to the user’s timezone for display. Never rely on implicit local timezone behavior.

Leverage immutability. Pass Temporal objects freely between functions without defensive copying. Chain operations without worrying about mutation.

The Temporal API represents a fundamental improvement over Date. While it requires a polyfill today, it’s worth adopting now to eliminate entire categories of bugs and improve code clarity. Your future self—and your teammates—will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.