| name | dates-and-times |
| description | World-class date, time, and timezone handling based on Jon Skeet's NodaTime principles and the TC39 Temporal API (Stage 4 / ES2026). Triggers when working with Date objects, Temporal types, timestamps, timezone conversions, scheduling, or any temporal logic. Use to avoid the countless bugs caused by datetime mishandling. |
Dates and Times
Date and time handling is one of the most error-prone areas in software development. This skill applies battle-tested principles from Jon Skeet's NodaTime, the TC39 Temporal API (Stage 4 / ES2026), and hard-won industry experience.
Core Philosophy
"When an API can't make the right choice automatically and reliably, it should force the developer to make the choice." — Jon Skeet
Know what you're representing. The source of most datetime bugs is conflating different concepts:
| Concept | What It Represents | Temporal Type | Examples |
|---|
| Instant | A point on the global timeline | Temporal.Instant | "When the server crashed", log timestamps |
| Local date/time | A date/time without timezone | Temporal.PlainDateTime | "December 25th at 3:00 PM" |
| Zoned date/time | Local time in a specific zone | Temporal.ZonedDateTime | "3:00 PM in Chicago", meeting times |
| Date only | A calendar date | Temporal.PlainDate | Birthdays, due dates, holidays |
| Time only | A time of day | Temporal.PlainTime | Store hours, alarm times |
| Year-month | A month in a year | Temporal.PlainYearMonth | Billing cycles, card expiry |
| Month-day | A recurring annual date | Temporal.PlainMonthDay | Birthdays, anniversaries |
| Duration | Elapsed time / calendar span | Temporal.Duration | "2 hours 30 minutes", "3 months" |
Immutability principle: Every Temporal operation returns a new object — nothing mutates. This eliminates an entire class of bugs caused by Date's mutable API (e.g., passing a Date to a function that silently calls .setDate() on your original).
Nanosecond precision: Temporal.Instant measures elapsed nanoseconds since the Unix epoch (not milliseconds like Date). This matters when interoperating with systems that produce nanosecond timestamps (databases, tracing systems, other languages).
The 34 Falsehoods
Programmers commonly believe these falsehoods about time:
- UTC offsets range from -12 to +12 (actually extends to +14)
- One UTC offset equals one timezone (multiple zones share offsets)
- Offsets are always whole hours (India: +5:30, Nepal: +5:45)
- DST is always 1 hour (Lord Howe Island: 30 minutes)
- DST follows March/October (southern hemisphere reverses this)
- Time conversions are always unambiguous (DST creates gaps and overlaps)
- "Just store UTC" solves everything (fails for future events)
- Every location has an official timezone (poles don't)
- Timezone abbreviations are unique ("CST" = 3 different zones)
- GPS coordinates + UTC = local time (disputed territories overlap)
Decision Framework
What to Store
┌─────────────────────────────────────────────────────────────┐
│ WHAT ARE YOU STORING? │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌──────────┐
│ INSTANT │ │ LOCAL TIME│ │ FUTURE │
│ (past) │ │ (no zone) │ │ EVENT │
└────┬────┘ └─────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌─────────────────┐
│ Store UTC │ │ Store as-is│ │ Store local time│
│ timestamptz│ │ No TZ info │ │ + timezone ID │
└────────────┘ └────────────┘ │ + derived UTC │
└─────────────────┘
The Future Events Problem
"Just store UTC" fails for future events because timezone rules change.
const conferenceStart = new Date("2025-06-15T09:00:00Z");
const conference = {
localTime: "09:00",
localDate: "2025-06-15",
timezone: "Europe/Amsterdam",
utcStart: "2025-06-15T07:00:00Z",
tzdbVersion: "2024a"
};
const conferenceZdt = Temporal.ZonedDateTime.from(
"2025-06-15T09:00:00+02:00[Europe/Amsterdam]"
);
JavaScript/TypeScript Patterns
Library Recommendations
| Library | Best For | Status |
|---|
| Temporal API (native) | New projects — use this first | Stage 4 / ES2026. Chrome 144+, Firefox 139+, Edge 144+, Node 26+, TS 6.0+ |
| date-fns + date-fns-tz | Legacy codebases, tree-shakable | Stable, ~18kb |
| Luxon | Legacy codebases, heavy timezone work | Stable, ~20kb |
| Day.js | Moment replacement, minimal | Stable, ~6kb |
Temporal is the default choice for new code. It is natively available in modern browsers and runtimes — no polyfill needed. Only reach for date-fns or Luxon when supporting older environments without Temporal.
The JavaScript Date Trap
new Date("2024-12-25");
new Date("2024-12-25T00:00:00");
new Date("2024-12-25T00:00:00Z");
Safe Patterns with date-fns
import { parseISO, format, addDays, startOfDay } from "date-fns";
import { formatInTimeZone, toZonedTime, fromZonedTime } from "date-fns-tz";
const instant = parseISO("2024-12-25T15:00:00Z");
const display = formatInTimeZone(
instant,
"America/Chicago",
"MMM d, yyyy h:mm a"
);
const chicagoNoon = fromZonedTime(
new Date(2024, 11, 25, 12, 0),
"America/Chicago"
);
const nextWeek = addDays(instant, 7);
Safe Patterns with Luxon
import { DateTime, Duration, Interval } from "luxon";
const meeting = DateTime.fromObject(
{ year: 2024, month: 12, day: 25, hour: 15 },
{ zone: "America/New_York" }
);
const inTokyo = meeting.setZone("Asia/Tokyo");
const ambiguous = DateTime.fromObject(
{ year: 2024, month: 11, day: 3, hour: 1, minute: 30 },
{ zone: "America/Chicago" }
);
meeting.toFormat("fff");
Safe Patterns with Temporal
const now = Temporal.Now.zonedDateTimeISO();
const instant = Temporal.Now.instant();
const zdt = Temporal.ZonedDateTime.from(
"2026-06-15T09:00:00+02:00[Europe/Amsterdam]"
);
const inst = Temporal.Instant.from("2026-02-25T15:15:00Z");
const date = Temporal.PlainDate.from({ year: 2026, month: 3, day: 11 });
const inLondon = inst.toZonedDateTimeISO("Europe/London");
const inNewYork = inst.toZonedDateTimeISO("America/New_York");
const nextWeek = date.add({ days: 7 });
const plus1h = zdt.add({ hours: 1 });
const threeMonthsAgo = date.subtract({ months: 3 });
const preDST = Temporal.ZonedDateTime.from(
"2026-03-29T00:30:00+00:00[Europe/London]"
);
const afterDST = preDST.add({ hours: 1 });
const duration = Temporal.Duration.from({ hours: 130, minutes: 20 });
duration.total({ unit: "seconds" });
const birthday = Temporal.PlainDate.from("1990-05-15");
birthday.year;
birthday.inLeapYear;
const storeOpen = Temporal.PlainTime.from("09:00");
const hebrewDate = Temporal.PlainDate.from("2026-03-11[u-ca=hebrew]");
hebrewDate.toLocaleString("en", { calendar: "hebrew" });
const nextHebrewMonth = hebrewDate.add({ months: 1 });
Temporal.PlainDate.compare(date, nextWeek);
const legacyDate = new Date("2024-12-25T15:00:00Z");
const fromLegacy = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime());
const backToLegacy = new Date(inst.epochMilliseconds);
Date-Only Values
For dates without times (birthdays, due dates), avoid time components:
const birthDate = Temporal.PlainDate.from("1990-05-15");
interface User {
birthday: string;
}
function toNoonUTC(dateString: string): Date {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
const bad = new Date("1990-05-15T00:00:00Z");
Database Patterns
PostgreSQL
CREATE TABLE events (
id UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
local_start TIME NOT NULL,
local_date DATE NOT NULL,
timezone TEXT NOT NULL,
starts_at TIMESTAMPTZ GENERATED ALWAYS AS (
(local_date + local_start) AT TIME ZONE timezone
) STORED
);
CREATE TABLE bad_events (
created_at TIMESTAMP NOT NULL
);
Querying Across Timezones
SELECT * FROM events
WHERE (starts_at AT TIME ZONE 'America/Chicago')::date = CURRENT_DATE;
SELECT * FROM events
WHERE starts_at BETWEEN now() AND now() + INTERVAL '24 hours';
Common Bugs and Fixes
Bug: Date shifts by one day
const due = Temporal.PlainDate.from("2024-12-25");
const dueLegacy = new Date(Date.UTC(2024, 11, 25, 12, 0, 0));
Bug: DST causes duplicate/missing hours
const zdt = Temporal.ZonedDateTime.from(
"2024-03-10T00:30:00-06:00[America/Chicago]"
);
const plus2h = zdt.add({ hours: 2 });
import { DateTime } from "luxon";
const result = DateTime.fromObject(
{ year: 2024, month: 3, day: 10, hour: 2, minute: 30 },
{ zone: "America/Chicago" }
);
if (!result.isValid) {
console.log("Invalid time:", result.invalidReason);
}
Bug: Timezone abbreviations are ambiguous
const bad = "2024-12-25T10:00:00 CST";
const good = { time: "2024-12-25T10:00:00", zone: "America/Chicago" };
Bug: Server timezone affects results
new Date(2024, 11, 25, 9, 0);
const meeting = Temporal.ZonedDateTime.from(
"2024-12-25T09:00:00+09:00[Asia/Tokyo]"
);
DateTime.fromObject(
{ year: 2024, month: 12, day: 25, hour: 9 },
{ zone: "Asia/Tokyo" }
);
Testing Time-Dependent Code
import { vi, describe, it, beforeEach, afterEach } from "vitest";
describe("time-dependent feature", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-06-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("handles DST correctly", () => {
vi.setSystemTime(new Date("2024-03-10T07:00:00Z"));
});
it("handles year boundaries", () => {
vi.setSystemTime(new Date("2024-12-31T23:59:59Z"));
});
});
Displaying Times to Users
const zdt = Temporal.ZonedDateTime.from(
"2024-12-25T15:00:00-06:00[America/Chicago]"
);
zdt.toLocaleString("en-US", {
dateStyle: "medium", timeStyle: "short", timeZoneName: "short"
});
"3:00 PM CST"
"3:00 PM (Chicago)"
"Dec 25 at 9pm your time"
"5 minutes ago"
"Yesterday at 3:00 PM"
"Last Tuesday"
"9:00 AM Pacific (12:00 PM your time)"
Checklist
Before shipping datetime code, verify:
References