The JavaScript Temporal API: Rethinking How We Handle Dates at the Architecture Level
The JavaScript Temporal API: Rethinking How We Handle Dates at the Architecture Level
JavaScript's Date object is fundamentally broken. Not quirky, not inconvenient—broken at an architectural level. It conflates instants with local times, ignores time zones, mutates in place, and handles edge cases incorrectly. Every library we've built (Moment, date-fns, Luxon) is a bandage over these wounds.
Temporal is different. It's not another library—it's a complete rethinking of how dates, times, durations, and calendars should work, designed by the people who've spent decades understanding what "correct" means in this domain.
This is a deep dive into Temporal: why it exists, how it works, and what it means for how we architect date handling across our systems.
Why Date Is Fundamentally Broken
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATE OBJECT DESIGN FLAWS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Conflates Two Different Concepts │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Date represents BOTH: │ │
│ │ • An instant in time (milliseconds since epoch) │ │
│ │ • A wall-clock time (year, month, day, hour, minute) │ │
│ │ │ │
│ │ const d = new Date('2025-03-09T02:30:00'); │ │
│ │ // In America/New_York, this time DOESN'T EXIST (DST skip) │ │
│ │ // Date creates it anyway, silently adjusting │ │
│ │ │ │
│ │ const d2 = new Date('2025-11-02T01:30:00'); │ │
│ │ // In America/New_York, this time exists TWICE (DST fallback) │ │
│ │ // Date picks one arbitrarily │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. No Real Timezone Support │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Date only knows: │ │
│ │ • UTC (getUTCHours) │ │
│ │ • Local system timezone (getHours) │ │
│ │ │ │
│ │ // Want Tokyo time on a US server? │ │
│ │ const d = new Date(); │ │
│ │ d.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' }); │ │
│ │ // This is STRING formatting only! │ │
│ │ // You can't DO MATH in Tokyo time │ │
│ │ │ │
│ │ // "Add one day in Tokyo" │ │
│ │ // Impossible with Date. DST in Tokyo? Date doesn't know. │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. Mutability │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ const birthday = new Date('1990-05-15'); │ │
│ │ const nextBirthday = birthday; │ │
│ │ nextBirthday.setFullYear(2025); │ │
│ │ │ │
│ │ console.log(birthday.getFullYear()); // 2025 (MUTATED!) │ │
│ │ │ │
│ │ // Every operation requires defensive copying │ │
│ │ const safe = new Date(birthday.getTime()); │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. Insane Month Indexing │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ new Date(2025, 0, 1) // January 1, 2025 (month 0??) │ │
│ │ new Date(2025, 11, 31) // December 31, 2025 (month 11??) │ │
│ │ new Date(2025, 12, 1) // January 1, 2026 (overflow, no error!) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 5. Parsing Is Broken │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ new Date('2025-01-15') // Parsed as UTC midnight │ │
│ │ new Date('2025/01/15') // Parsed as LOCAL midnight │ │
│ │ new Date('01-15-2025') // Invalid Date (or browser-dep.) │ │
│ │ new Date('January 15, 2025') // Works in Chrome, fails in Safari │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 6. No Duration/Period Concept │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ // "3 months from now" │ │
│ │ const d = new Date(); │ │
│ │ d.setMonth(d.getMonth() + 3); │ │
│ │ │ │
│ │ // January 31 + 3 months = April 31? │ │
│ │ // Date makes it May 1 (silent overflow) │ │
│ │ │ │
│ │ // "What's the difference between two dates?" │ │
│ │ const diff = date2 - date1; // Milliseconds only │ │
│ │ // How many months? Years? Can't know without complex math │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Temporal's Architecture
Temporal introduces distinct types for distinct concepts. This isn't arbitrary complexity—it's modeling reality correctly.
┌─────────────────────────────────────────────────────────────────────────────┐
│ TEMPORAL TYPE SYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ EXACT TIME (Instants) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Temporal.Instant │ │
│ │ └─► A point on the universal timeline │ │
│ │ └─► Nanoseconds since Unix epoch │ │
│ │ └─► No timezone, no calendar │ │
│ │ └─► Use for: timestamps, logging, "when did this happen" │ │
│ │ │ │
│ │ Temporal.ZonedDateTime │ │
│ │ └─► Instant + timezone + calendar │ │
│ │ └─► "Wall-clock time in a specific place" │ │
│ │ └─► Use for: scheduling, "meeting at 3pm Tokyo time" │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ CIVIL TIME (Calendar/Wall-Clock) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Temporal.PlainDateTime │ │
│ │ └─► Year, month, day, hour, minute, second, nanosecond │ │
│ │ └─► No timezone (intentionally!) │ │
│ │ └─► Use for: "form input", "user entered 3:00 PM" │ │
│ │ │ │
│ │ Temporal.PlainDate │ │
│ │ └─► Year, month, day only │ │
│ │ └─► Use for: birthdays, holidays, "date picker value" │ │
│ │ │ │
│ │ Temporal.PlainTime │ │
│ │ └─► Hour, minute, second, nanosecond only │ │
│ │ └─► Use for: "store opens at 9:00 AM" (no date) │ │
│ │ │ │
│ │ Temporal.PlainYearMonth │ │
│ │ └─► Year and month only │ │
│ │ └─► Use for: "credit card expiry", "billing period" │ │
│ │ │ │
│ │ Temporal.PlainMonthDay │ │
│ │ └─► Month and day only │ │
│ │ └─► Use for: "annual anniversary" (Feb 14th every year) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ DURATIONS │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Temporal.Duration │ │
│ │ └─► Length of time: years, months, weeks, days, hours, etc. │ │
│ │ └─► Can be calendar-aware (3 months = variable days) │ │
│ │ └─► Use for: "add 2 weeks", "duration between events" │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Type Selection Guide: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ "When exactly did this happen?" → Instant │ │
│ │ "Meeting at 3pm in Tokyo" → ZonedDateTime │ │
│ │ "User typed 2025-03-15 14:30" → PlainDateTime │ │
│ │ "Birthday: March 15" → PlainDate │ │
│ │ "Store opens at 9am" → PlainTime │ │
│ │ "Card expires 03/2027" → PlainYearMonth │ │
│ │ "Valentine's Day" → PlainMonthDay │ │
│ │ "Took 2 hours 30 minutes" → Duration │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Temporal in Practice
Creating Temporal Objects
// Temporal.Instant - exact moment in time
const now = Temporal.Now.instant();
const fromEpoch = Temporal.Instant.fromEpochMilliseconds(1708531200000);
const fromString = Temporal.Instant.from('2025-02-21T15:30:00Z');
console.log(now.epochMilliseconds); // Unix timestamp
console.log(now.epochNanoseconds); // BigInt, nanosecond precision
// Temporal.ZonedDateTime - instant + timezone + calendar
const tokyoNow = Temporal.Now.zonedDateTimeISO('Asia/Tokyo');
const meeting = Temporal.ZonedDateTime.from({
year: 2025,
month: 3,
day: 15,
hour: 14,
minute: 30,
timeZone: 'America/New_York',
});
console.log(meeting.toString());
// "2025-03-15T14:30:00-04:00[America/New_York]"
// Convert between timezones (CORRECTLY handles DST)
const meetingInTokyo = meeting.withTimeZone('Asia/Tokyo');
console.log(meetingInTokyo.toString());
// "2025-03-16T03:30:00+09:00[Asia/Tokyo]"
// Temporal.PlainDate - calendar date, no time, no timezone
const birthday = Temporal.PlainDate.from('1990-05-15');
const fromParts = Temporal.PlainDate.from({ year: 1990, month: 5, day: 15 });
console.log(birthday.year); // 1990
console.log(birthday.month); // 5 (not 4!)
console.log(birthday.day); // 15
// Temporal.PlainTime - time of day, no date
const openingTime = Temporal.PlainTime.from('09:00:00');
const closingTime = Temporal.PlainTime.from({ hour: 17, minute: 30 });
// Temporal.PlainDateTime - date + time, no timezone
const appointment = Temporal.PlainDateTime.from('2025-03-15T14:30:00');
const userInput = Temporal.PlainDateTime.from({
year: 2025,
month: 3,
day: 15,
hour: 14,
minute: 30,
});
// Temporal.Duration
const twoWeeks = Temporal.Duration.from({ weeks: 2 });
const complex = Temporal.Duration.from({
years: 1,
months: 2,
days: 3,
hours: 4,
minutes: 5,
});
const fromString2 = Temporal.Duration.from('P1Y2M3DT4H5M'); // ISO 8601
Arithmetic That Actually Works
// Adding duration to dates
const today = Temporal.PlainDate.from('2025-01-31');
// "Add one month" - what does that mean?
const nextMonth = today.add({ months: 1 });
console.log(nextMonth.toString()); // "2025-02-28" (clamped, not overflowed!)
// With explicit overflow handling
const overflow = today.add({ months: 1 }, { overflow: 'reject' });
// Throws RangeError: day 31 is out of range for month 2
const constrained = today.add({ months: 1 }, { overflow: 'constrain' });
// "2025-02-28" (default behavior)
// Subtracting
const lastWeek = today.subtract({ weeks: 1 });
// Chaining
const future = today
.add({ months: 3 })
.add({ days: 15 })
.subtract({ weeks: 1 });
// Duration between dates
const start = Temporal.PlainDate.from('2025-01-15');
const end = Temporal.PlainDate.from('2025-04-20');
const diff = start.until(end);
console.log(diff.toString()); // "P95D" (95 days)
// With specific units
const diffMonths = start.until(end, { largestUnit: 'month' });
console.log(diffMonths.toString()); // "P3M5D" (3 months, 5 days)
// Direction matters
const diffReverse = end.until(start);
console.log(diffReverse.toString()); // "-P95D" (negative)
// since() vs until()
start.until(end); // Positive when end > start
end.since(start); // Same result, different semantics
Timezone Handling Done Right
// The DST problem: March 9, 2025, 2:30 AM doesn't exist in New York
// Clocks jump from 2:00 AM to 3:00 AM
// With Date (broken):
const badDate = new Date('2025-03-09T02:30:00');
console.log(badDate); // Creates a time that doesn't exist
// With Temporal (correct):
const zonedTime = Temporal.ZonedDateTime.from({
year: 2025,
month: 3,
day: 9,
hour: 2,
minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'reject' });
// Throws! This time doesn't exist.
// Or handle automatically
const adjusted = Temporal.ZonedDateTime.from({
year: 2025,
month: 3,
day: 9,
hour: 2,
minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'compatible' }); // Default
// Adjusts to 3:30 AM (after the skip)
// The other DST problem: November 2, 2025, 1:30 AM exists TWICE in New York
// Clocks fall back from 2:00 AM to 1:00 AM
const ambiguous = Temporal.ZonedDateTime.from({
year: 2025,
month: 11,
day: 2,
hour: 1,
minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'earlier' }); // First occurrence (before fallback)
const ambiguousLater = Temporal.ZonedDateTime.from({
year: 2025,
month: 11,
day: 2,
hour: 1,
minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'later' }); // Second occurrence (after fallback)
console.log(ambiguous.offsetNanoseconds); // -14400000000000 (UTC-4)
console.log(ambiguousLater.offsetNanoseconds); // -18000000000000 (UTC-5)
// Adding time across DST boundaries
const beforeDST = Temporal.ZonedDateTime.from('2025-03-09T01:00:00[America/New_York]');
const afterAdding2Hours = beforeDST.add({ hours: 2 });
console.log(afterAdding2Hours.toString());
// "2025-03-09T04:00:00-04:00[America/New_York]"
// Correctly accounts for the DST skip!
Calendars Beyond Gregorian
// Temporal supports non-Gregorian calendars natively
const gregorianDate = Temporal.PlainDate.from('2025-02-21');
// Convert to other calendars
const hebrewDate = gregorianDate.withCalendar('hebrew');
console.log(hebrewDate.toString()); // "2025-02-21[u-ca=hebrew]"
console.log(hebrewDate.year); // 5785 (Hebrew year)
console.log(hebrewDate.month); // 12 (Adar I)
console.log(hebrewDate.day); // 23
const islamicDate = gregorianDate.withCalendar('islamic');
console.log(islamicDate.year); // 1446
console.log(islamicDate.month); // 8 (Sha'ban)
const japaneseDate = gregorianDate.withCalendar('japanese');
console.log(japaneseDate.era); // "reiwa"
console.log(japaneseDate.year); // 7 (Reiwa 7)
// Create directly in another calendar
const persianNewYear = Temporal.PlainDate.from({
year: 1404,
month: 1,
day: 1,
calendar: 'persian',
});
console.log(persianNewYear.toString()); // "1404-01-01[u-ca=persian]"
// Convert to Gregorian for storage/transmission
const gregorianEquivalent = persianNewYear.withCalendar('iso8601');
console.log(gregorianEquivalent.toString()); // "2025-03-21"
// Arithmetic respects calendar
const hebrewMonth = Temporal.PlainDate.from({
year: 5785,
month: 6, // Adar (or Adar I in leap years)
day: 1,
calendar: 'hebrew',
});
const nextHebrewMonth = hebrewMonth.add({ months: 1 });
// Correctly handles leap months in Hebrew calendar
Migration from Moment/date-fns
Pattern Mapping
// ═══════════════════════════════════════════════════════════════
// MOMENT.JS → TEMPORAL
// ═══════════════════════════════════════════════════════════════
// Current time
// Moment: moment()
// Temporal:
const now = Temporal.Now.zonedDateTimeISO();
// Parse string
// Moment: moment('2025-03-15')
// Temporal:
const date = Temporal.PlainDate.from('2025-03-15');
// Parse with format
// Moment: moment('15/03/2025', 'DD/MM/YYYY')
// Temporal: (use a parser library, Temporal is strict ISO 8601)
const parsed = Temporal.PlainDate.from(parseCustomFormat('15/03/2025', 'DD/MM/YYYY'));
// Format output
// Moment: moment().format('YYYY-MM-DD')
// Temporal:
const formatted = date.toString(); // '2025-03-15'
// For custom formats, use Intl.DateTimeFormat or template literals
const custom = `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
// Add/subtract
// Moment: moment().add(2, 'weeks')
// Temporal:
const futureDate = Temporal.Now.plainDateISO().add({ weeks: 2 });
// Difference
// Moment: moment(end).diff(moment(start), 'days')
// Temporal:
const start = Temporal.PlainDate.from('2025-01-01');
const end = Temporal.PlainDate.from('2025-03-15');
const diffDays = start.until(end, { largestUnit: 'day' }).days; // 73
// Start/end of unit
// Moment: moment().startOf('month')
// Temporal:
const startOfMonth = Temporal.PlainDate.from({
year: date.year,
month: date.month,
day: 1,
});
// Moment: moment().endOf('month')
// Temporal:
const endOfMonth = startOfMonth.add({ months: 1 }).subtract({ days: 1 });
// Timezone conversion
// Moment: moment.tz('2025-03-15 14:30', 'America/New_York')
// Temporal:
const zoned = Temporal.ZonedDateTime.from({
year: 2025, month: 3, day: 15, hour: 14, minute: 30,
timeZone: 'America/New_York',
});
// ═══════════════════════════════════════════════════════════════
// DATE-FNS → TEMPORAL
// ═══════════════════════════════════════════════════════════════
// Current time
// date-fns: new Date()
// Temporal:
const nowInstant = Temporal.Now.instant();
// Add duration
// date-fns: addDays(date, 5)
// Temporal:
const plusFive = date.add({ days: 5 });
// date-fns: addMonths(date, 2)
// Temporal:
const plusMonths = date.add({ months: 2 });
// Difference
// date-fns: differenceInDays(end, start)
// Temporal:
const days = start.until(end, { largestUnit: 'day' }).total('day');
// date-fns: differenceInMonths(end, start)
// Temporal:
const months = start.until(end, { largestUnit: 'month' }).total('month');
// Comparison
// date-fns: isBefore(date1, date2)
// Temporal:
const isBefore = Temporal.PlainDate.compare(date1, date2) < 0;
// date-fns: isAfter(date1, date2)
// Temporal:
const isAfter = Temporal.PlainDate.compare(date1, date2) > 0;
// date-fns: isEqual(date1, date2)
// Temporal:
const isEqual = date1.equals(date2);
// Format
// date-fns: format(date, 'yyyy-MM-dd')
// Temporal: Use Intl.DateTimeFormat
const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const formattedDate = formatter.format(
new Date(date.year, date.month - 1, date.day)
);
Migration Strategy
// Step 1: Create adapter layer
// lib/temporal/adapter.ts
import { Temporal } from '@js-temporal/polyfill';
// Conversion utilities
export function dateToPlainDate(date: Date): Temporal.PlainDate {
return Temporal.PlainDate.from({
year: date.getFullYear(),
month: date.getMonth() + 1, // Fix 0-indexing
day: date.getDate(),
});
}
export function plainDateToDate(pd: Temporal.PlainDate): Date {
return new Date(pd.year, pd.month - 1, pd.day);
}
export function dateToInstant(date: Date): Temporal.Instant {
return Temporal.Instant.fromEpochMilliseconds(date.getTime());
}
export function instantToDate(instant: Temporal.Instant): Date {
return new Date(instant.epochMilliseconds);
}
export function dateToZonedDateTime(
date: Date,
timeZone: string
): Temporal.ZonedDateTime {
return Temporal.Instant
.fromEpochMilliseconds(date.getTime())
.toZonedDateTimeISO(timeZone);
}
// Step 2: Create drop-in replacements for common operations
export const TemporalUtils = {
now(): Temporal.ZonedDateTime {
return Temporal.Now.zonedDateTimeISO();
},
today(): Temporal.PlainDate {
return Temporal.Now.plainDateISO();
},
parse(input: string): Temporal.PlainDate | Temporal.PlainDateTime {
// Try PlainDateTime first
try {
return Temporal.PlainDateTime.from(input);
} catch {
return Temporal.PlainDate.from(input);
}
},
addDays(date: Temporal.PlainDate, days: number): Temporal.PlainDate {
return date.add({ days });
},
addMonths(date: Temporal.PlainDate, months: number): Temporal.PlainDate {
return date.add({ months });
},
diffInDays(
start: Temporal.PlainDate,
end: Temporal.PlainDate
): number {
return start.until(end).total('day');
},
format(
date: Temporal.PlainDate | Temporal.PlainDateTime | Temporal.ZonedDateTime,
locale: string = 'en-US'
): string {
if (date instanceof Temporal.ZonedDateTime) {
return date.toLocaleString(locale);
}
// Convert to Date for Intl formatting
const d = 'hour' in date
? new Date(date.year, date.month - 1, date.day, date.hour, date.minute)
: new Date(date.year, date.month - 1, date.day);
return d.toLocaleDateString(locale);
},
};
// Step 3: Gradual migration in components
// Before (moment):
import moment from 'moment';
const displayDate = moment(event.date).format('MMMM D, YYYY');
// After (Temporal):
import { Temporal } from '@js-temporal/polyfill';
const date = Temporal.PlainDate.from(event.date);
const displayDate = date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
Backend/Frontend Date Contract Design
The key insight: different parts of your system need different Temporal types. Designing the contract properly prevents data loss and timezone bugs.
The Contract Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATE CONTRACT ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Storage Layer (Database) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ TIMESTAMP WITH TIME ZONE (PostgreSQL) │ │
│ │ └─► Store as Temporal.Instant (epoch + zone in separate column) │ │
│ │ └─► For: events, logs, "when did this happen" │ │
│ │ │ │
│ │ DATE (PostgreSQL) │ │
│ │ └─► Store as Temporal.PlainDate │ │
│ │ └─► For: birthdays, holidays, calendar dates │ │
│ │ │ │
│ │ TIME (PostgreSQL) │ │
│ │ └─► Store as Temporal.PlainTime │ │
│ │ └─► For: recurring schedules, "opens at 9am" │ │
│ │ │ │
│ │ TEXT + TEXT (PostgreSQL) │ │
│ │ └─► Store Instant string + timezone ID │ │
│ │ └─► For: scheduled future events (preserve user intent) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ API Layer (JSON) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Instants: ISO 8601 with Z suffix │ │
│ │ { "createdAt": "2025-02-21T15:30:00.000Z" } │ │
│ │ │ │
│ │ ZonedDateTime: ISO 8601 with offset and zone │ │
│ │ { "meetingTime": "2025-03-15T14:30:00-04:00[America/New_York]" } │ │
│ │ │ │
│ │ PlainDate: ISO 8601 date only │ │
│ │ { "birthday": "1990-05-15" } │ │
│ │ │ │
│ │ PlainTime: ISO 8601 time only │ │
│ │ { "opensAt": "09:00:00" } │ │
│ │ │ │
│ │ Duration: ISO 8601 duration │ │
│ │ { "duration": "PT2H30M" } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Frontend Layer │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Parse API → Temporal type │ │
│ │ Temporal type → Display (Intl) │ │
│ │ User input → Temporal type → API │ │
│ │ │ │
│ │ Always explicit about timezone: │ │
│ │ • Display times in user's timezone │ │
│ │ • Input includes timezone context │ │
│ │ • Never assume server timezone = user timezone │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
API Schema Design
// types/api.ts
// Events that happened (instants)
interface AuditLog {
id: string;
action: string;
timestamp: string; // ISO 8601 instant: "2025-02-21T15:30:00.000Z"
}
// Scheduled events (zoned)
interface Meeting {
id: string;
title: string;
// Full ZonedDateTime string preserves timezone
scheduledFor: string; // "2025-03-15T14:30:00-04:00[America/New_York]"
duration: string; // "PT1H30M"
}
// Calendar dates (plain)
interface Person {
id: string;
name: string;
birthday: string; // "1990-05-15" (no time, no zone)
}
// Business hours (plain time)
interface Store {
id: string;
name: string;
opensAt: string; // "09:00:00"
closesAt: string; // "17:30:00"
timezone: string; // "America/New_York" (stored separately)
}
// Serialization utilities
// lib/temporal/serialization.ts
import { Temporal } from '@js-temporal/polyfill';
export const TemporalJSON = {
// Serialize for API
serialize(temporal: Temporal.Instant | Temporal.ZonedDateTime | Temporal.PlainDate | Temporal.PlainTime | Temporal.Duration): string {
return temporal.toString();
},
// Parse from API
parseInstant(s: string): Temporal.Instant {
return Temporal.Instant.from(s);
},
parseZonedDateTime(s: string): Temporal.ZonedDateTime {
return Temporal.ZonedDateTime.from(s);
},
parsePlainDate(s: string): Temporal.PlainDate {
return Temporal.PlainDate.from(s);
},
parsePlainTime(s: string): Temporal.PlainTime {
return Temporal.PlainTime.from(s);
},
parseDuration(s: string): Temporal.Duration {
return Temporal.Duration.from(s);
},
};
// Zod schemas for validation
import { z } from 'zod';
export const InstantSchema = z.string().refine(
(s) => {
try {
Temporal.Instant.from(s);
return true;
} catch {
return false;
}
},
{ message: 'Invalid ISO 8601 instant' }
);
export const ZonedDateTimeSchema = z.string().refine(
(s) => {
try {
Temporal.ZonedDateTime.from(s);
return true;
} catch {
return false;
}
},
{ message: 'Invalid ISO 8601 zoned datetime' }
);
export const PlainDateSchema = z.string().regex(
/^\d{4}-\d{2}-\d{2}$/,
'Must be YYYY-MM-DD'
).refine(
(s) => {
try {
Temporal.PlainDate.from(s);
return true;
} catch {
return false;
}
},
{ message: 'Invalid date' }
);
export const DurationSchema = z.string().refine(
(s) => {
try {
Temporal.Duration.from(s);
return true;
} catch {
return false;
}
},
{ message: 'Invalid ISO 8601 duration' }
);
Database Patterns
// Prisma schema
// schema.prisma
model Event {
id String @id @default(uuid())
title String
// Option 1: Store instant as timestamptz
// Good for: past events, logs
createdAt DateTime @default(now()) @db.Timestamptz
// Option 2: Store zoned datetime as string
// Good for: scheduled events where timezone matters
scheduledAt String // "2025-03-15T14:30:00-04:00[America/New_York]"
// Option 3: Store components separately
// Good for: recurring events, complex timezone logic
scheduledDate String // "2025-03-15"
scheduledTime String // "14:30:00"
timezone String // "America/New_York"
}
model Person {
id String @id @default(uuid())
name String
// Plain date - no timezone (birthday is same everywhere)
birthday String // "1990-05-15"
}
// Repository pattern
// repositories/eventRepository.ts
import { Temporal } from '@js-temporal/polyfill';
import { prisma } from '@/lib/prisma';
export const EventRepository = {
async create(data: {
title: string;
scheduledAt: Temporal.ZonedDateTime;
}) {
return prisma.event.create({
data: {
title: data.title,
scheduledAt: data.scheduledAt.toString(),
},
});
},
async findUpcoming(timezone: string): Promise<EventWithTemporal[]> {
const now = Temporal.Now.instant();
const events = await prisma.event.findMany({
orderBy: { scheduledAt: 'asc' },
});
return events
.map((e) => ({
...e,
scheduledAtTemporal: Temporal.ZonedDateTime.from(e.scheduledAt),
}))
.filter((e) =>
Temporal.Instant.compare(
e.scheduledAtTemporal.toInstant(),
now
) > 0
)
.map((e) => ({
...e,
// Convert to user's timezone for display
displayTime: e.scheduledAtTemporal.withTimeZone(timezone),
}));
},
};
React Components
// components/temporal/DatePicker.tsx
'use client';
import { Temporal } from '@js-temporal/polyfill';
import { useState, useCallback } from 'react';
interface DatePickerProps {
value: Temporal.PlainDate | null;
onChange: (date: Temporal.PlainDate | null) => void;
min?: Temporal.PlainDate;
max?: Temporal.PlainDate;
}
export function TemporalDatePicker({
value,
onChange,
min,
max,
}: DatePickerProps) {
// Convert Temporal to input value
const inputValue = value?.toString() ?? '';
// Convert min/max for native input
const minValue = min?.toString();
const maxValue = max?.toString();
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (!val) {
onChange(null);
return;
}
try {
const date = Temporal.PlainDate.from(val);
onChange(date);
} catch {
// Invalid date, ignore
}
},
[onChange]
);
return (
<input
type="date"
value={inputValue}
onChange={handleChange}
min={minValue}
max={maxValue}
/>
);
}
// components/temporal/ZonedDateTimePicker.tsx
interface ZonedDateTimePickerProps {
value: Temporal.ZonedDateTime | null;
onChange: (zdt: Temporal.ZonedDateTime | null) => void;
timezone: string;
}
export function ZonedDateTimePicker({
value,
onChange,
timezone,
}: ZonedDateTimePickerProps) {
const [dateValue, setDateValue] = useState(
value?.toPlainDate().toString() ?? ''
);
const [timeValue, setTimeValue] = useState(
value?.toPlainTime().toString().slice(0, 5) ?? ''
);
const updateValue = useCallback(
(newDate: string, newTime: string) => {
if (!newDate || !newTime) {
onChange(null);
return;
}
try {
const zdt = Temporal.ZonedDateTime.from({
...Temporal.PlainDate.from(newDate).getISOFields(),
...Temporal.PlainTime.from(newTime).getISOFields(),
timeZone: timezone,
});
onChange(zdt);
} catch {
// Invalid combination
}
},
[onChange, timezone]
);
return (
<div className="flex gap-2">
<input
type="date"
value={dateValue}
onChange={(e) => {
setDateValue(e.target.value);
updateValue(e.target.value, timeValue);
}}
/>
<input
type="time"
value={timeValue}
onChange={(e) => {
setTimeValue(e.target.value);
updateValue(dateValue, e.target.value);
}}
/>
<span className="text-sm text-gray-500">
{timezone}
</span>
</div>
);
}
// components/temporal/RelativeTime.tsx
interface RelativeTimeProps {
instant: Temporal.Instant;
locale?: string;
}
export function RelativeTime({
instant,
locale = 'en-US',
}: RelativeTimeProps) {
const now = Temporal.Now.instant();
const duration = now.until(instant);
// Find the appropriate unit
const totalSeconds = Math.abs(duration.total('second'));
const isPast = Temporal.Instant.compare(instant, now) < 0;
let value: number;
let unit: Intl.RelativeTimeFormatUnit;
if (totalSeconds < 60) {
value = Math.round(totalSeconds);
unit = 'second';
} else if (totalSeconds < 3600) {
value = Math.round(totalSeconds / 60);
unit = 'minute';
} else if (totalSeconds < 86400) {
value = Math.round(totalSeconds / 3600);
unit = 'hour';
} else if (totalSeconds < 604800) {
value = Math.round(totalSeconds / 86400);
unit = 'day';
} else if (totalSeconds < 2592000) {
value = Math.round(totalSeconds / 604800);
unit = 'week';
} else if (totalSeconds < 31536000) {
value = Math.round(totalSeconds / 2592000);
unit = 'month';
} else {
value = Math.round(totalSeconds / 31536000);
unit = 'year';
}
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const formatted = rtf.format(isPast ? -value : value, unit);
return (
<time dateTime={instant.toString()}>
{formatted}
</time>
);
}
Polyfill Strategy (Until Native Support)
// lib/temporal/index.ts
// Central Temporal export with polyfill
// Option 1: Always polyfill (safest)
export { Temporal } from '@js-temporal/polyfill';
// Option 2: Use native when available (future-proof)
export const Temporal =
(globalThis as any).Temporal ??
(await import('@js-temporal/polyfill')).Temporal;
// Option 3: Conditional import (build-time optimization)
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
'temporal-polyfill':
process.env.USE_NATIVE_TEMPORAL
? 'temporal-polyfill/native' // Doesn't exist yet, future
: '@js-temporal/polyfill',
},
},
});
// Bundle size consideration
// @js-temporal/polyfill is ~40KB gzipped
// Consider:
// 1. Only import on pages that need dates
// 2. Use dynamic import for non-critical paths
// 3. Tree-shake by importing specific classes
// Optimal import pattern
import {
Temporal,
// Only import what you need
} from '@js-temporal/polyfill';
// Instead of
import { Temporal } from '@js-temporal/polyfill';
// Which imports everything
Production Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ TEMPORAL MIGRATION CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Type Selection │
│ □ Audit all date fields and categorize: │
│ □ Instants (when did this happen) │
│ □ ZonedDateTime (scheduled events) │
│ □ PlainDate (birthdays, holidays) │
│ □ PlainTime (business hours) │
│ □ Duration (time spans) │
│ │
│ API Contract │
│ □ Define serialization format per type │
│ □ Document timezone handling expectations │
│ □ Add Zod/validation schemas │
│ □ Version API if breaking changes needed │
│ │
│ Database │
│ □ Choose storage strategy per field type │
│ □ Plan migration for existing data │
│ □ Add indexes on date columns as needed │
│ □ Test timezone-aware queries │
│ │
│ Frontend │
│ □ Create Temporal-aware form components │
│ □ Implement display formatters │
│ □ Handle user timezone detection │
│ □ Test with multiple timezones │
│ │
│ Migration │
│ □ Create adapter layer for gradual migration │
│ □ Add Temporal alongside existing Date code │
│ □ Migrate component by component │
│ □ Remove legacy date library when complete │
│ │
│ Testing │
│ □ Test DST transitions (March/November) │
│ □ Test leap years and edge dates │
│ □ Test non-Gregorian calendars if needed │
│ □ Test with real timezone data │
│ │
│ Bundle │
│ □ Measure polyfill impact (~40KB gzipped) │
│ □ Consider code splitting for date-heavy pages │
│ □ Plan for native Temporal adoption │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
Temporal isn't just a better Date API. It's a fundamentally different model:
-
Separate concepts get separate types. Instants, zoned times, plain dates, and durations are different things. Treating them the same causes bugs.
-
Timezones are first-class. Every timezone-aware operation explicitly handles DST transitions. No more silent corruption.
-
Immutability by default. Every operation returns a new object. No defensive copying, no mutation bugs.
-
Calendars beyond Gregorian. International applications can use Hebrew, Islamic, Persian, and other calendars natively.
-
Duration math that makes sense. "Add 3 months" means add 3 months, not "add some number of days that's approximately 3 months."
The migration cost is real: learning new APIs, updating contracts, handling the polyfill. But the payoff is correctness—no more timezone bugs at 2am, no more DST errors twice a year, no more ambiguous date strings.
Date handling is infrastructure. Build it correctly once with Temporal, and it stops being a source of bugs forever.
What did you think?