JSON Date Handling in JavaScript: ISO 8601, Reviver & Timezones

Last updated:

JSON has no native date type — dates must be serialized as ISO 8601 strings ("2024-01-15T10:30:00Z") or Unix timestamps (milliseconds since epoch: 1705312200000). JSON.stringify() converts Date objects to ISO 8601 strings automatically via Date.prototype.toJSON(); JSON.parse() does NOT restore them — you must use a reviver function or post-process the parsed object. This guide covers ISO 8601 vs Unix timestamp tradeoffs, JSON.parse() reviver patterns for auto-conversion, timezone-safe serialization, and integration with date-fns and the TC39 Temporal API. Every pattern includes TypeScript types.

ISO 8601 vs Unix Timestamp: Which Format to Use in JSON

ISO 8601 and Unix timestamps both represent moments in time, but they trade readability for compactness. ISO 8601 strings like "2024-01-15T10:30:00.000Z" are human-readable, self-documenting, and sort lexicographically (alphabetical order equals chronological order for UTC strings). Unix milliseconds like 1705312200000 are compact, arithmetic-friendly, and unambiguous — there is no timezone confusion because they are always relative to the UTC epoch. Use ISO 8601 for public APIs, configuration files, and any data that humans inspect directly. Use Unix milliseconds for internal event logs, performance measurements, and database primary keys where compactness and arithmetic speed matter.

// ── ISO 8601 vs Unix timestamp comparison ────────────────────────
const now = new Date("2024-01-15T10:30:00.000Z");

// ISO 8601 — human-readable, includes timezone explicitly
const iso = now.toISOString();       // "2024-01-15T10:30:00.000Z"

// Unix milliseconds — compact integer, always UTC
const unixMs = now.getTime();        // 1705312200000  (13 digits)

// Unix seconds — 10-digit integer (older APIs, POSIX)
const unixSec = Math.floor(now.getTime() / 1000);  // 1705312200

// ── CRITICAL: Never mix ms and seconds ───────────────────────────
// 1705312200    → 1705312200 seconds → year 2024  ✓
// 1705312200000 → treated as seconds → year 56,000 ✗ (off by 1000x)

function isMsTimestamp(ts: number): boolean {
  // Heuristic: ms timestamps are 13 digits, seconds are 10 digits
  return ts > 1e12;
}

// ── ISO 8601 string features ──────────────────────────────────────
"2024-01-15T10:30:00Z"          // UTC (Z suffix)
"2024-01-15T10:30:00.000Z"      // UTC with milliseconds
"2024-01-15T05:30:00-05:00"     // EST offset explicitly
"2024-01-15T10:30:00"           // ⚠ NO OFFSET — local time, ambiguous

// Sorting ISO 8601 UTC strings works lexicographically:
const dates = [
  "2024-03-01T00:00:00.000Z",
  "2024-01-15T10:30:00.000Z",
  "2024-06-20T08:00:00.000Z",
];
dates.sort();
// ["2024-01-15T10:30:00.000Z", "2024-03-01T00:00:00.000Z", "2024-06-20T08:00:00.000Z"]
// Lexicographic sort = chronological sort for UTC ISO strings ✓

// ── JSON serialization comparison ────────────────────────────────
// ISO 8601 payload (24 bytes per date)
const isoPayload = JSON.stringify({ createdAt: now.toISOString() });
// '{"createdAt":"2024-01-15T10:30:00.000Z"}'

// Unix ms payload (13 bytes per date)
const tsPayload = JSON.stringify({ createdAt: now.getTime() });
// '{"createdAt":1705312200000}'

// ── Decision matrix ───────────────────────────────────────────────
// Public REST APIs        → ISO 8601  (human-readable, standard)
// GraphQL schemas         → ISO 8601  (DateTime scalar)
// Event logs / metrics    → Unix ms   (compact, arithmetic)
// Database primary keys   → Unix ms   (integer index efficiency)
// Config / display fields → ISO 8601  (self-documenting)
// Cross-language systems  → Unix ms   (no parsing disagreement)

The most dangerous mistake is mixing 10-digit (seconds) and 13-digit (milliseconds) Unix timestamps in the same system. A timestamp of 1705312200 interpreted as milliseconds gives year 56,000; interpreted as seconds it correctly gives January 2024. Always validate timestamp magnitude before using it — a simple check (ts > 1e12) distinguishes millisecond from second timestamps with high reliability for any date after 2001. The JSON data types guide covers the full set of primitive types JSON supports, which excludes Date natively.

JSON.stringify() Date Serialization and toJSON()

JSON.stringify() calls .toJSON() on any object that has that method — including all Date instances.Date.prototype.toJSON() returns the same string as .toISOString(): UTC with milliseconds and a Z suffix. This behavior is automatic and lossless. You can customize serialization by defining toJSON() on your own objects, or by passing a replacer function as the second argument to JSON.stringify().

// ── Basic Date serialization ──────────────────────────────────────
const date = new Date("2024-01-15T10:30:00.000Z");

JSON.stringify(date);
// '"2024-01-15T10:30:00.000Z"'  — quoted ISO string

JSON.stringify({ createdAt: date, name: "Alice" });
// '{"createdAt":"2024-01-15T10:30:00.000Z","name":"Alice"}'

JSON.stringify([date, date]);
// '["2024-01-15T10:30:00.000Z","2024-01-15T10:30:00.000Z"]'

// ── How toJSON() works ────────────────────────────────────────────
// Date.prototype.toJSON calls toISOString() internally:
date.toJSON() === date.toISOString();  // true
// Both return "2024-01-15T10:30:00.000Z"

// ── Custom toJSON() on your own objects ──────────────────────────
class Event {
  name: string;
  startTime: Date;

  constructor(name: string, startTime: Date) {
    this.name = name;
    this.startTime = startTime;
  }

  // Override toJSON to serialize startTime as Unix ms
  toJSON() {
    return {
      name: this.name,
      startTime: this.startTime.getTime(),  // Unix ms integer
    };
  }
}

const event = new Event("Launch", new Date("2024-01-15T10:30:00Z"));
JSON.stringify(event);
// '{"name":"Launch","startTime":1705312200000}'

// ── replacer function: customize per field ────────────────────────
function dateReplacer(key: string, value: unknown): unknown {
  // JSON.stringify calls toJSON() before the replacer,
  // so Date values arrive here as ISO strings
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
    // Convert ISO string back to Unix ms for compact serialization
    return new Date(value).getTime();
  }
  return value;
}

JSON.stringify({ createdAt: new Date(), score: 42 }, dateReplacer);
// '{"createdAt":1705312200000,"score":42}'

// ── replacer for selective field serialization ────────────────────
const ALLOWED_FIELDS = ["id", "name", "createdAt"];
JSON.stringify(
  { id: 1, name: "Alice", password: "secret", createdAt: new Date() },
  ALLOWED_FIELDS  // array replacer — include only these keys
);
// '{"id":1,"name":"Alice","createdAt":"2024-01-15T10:30:00.000Z"}'

// ── Dates in Map and Set — require custom replacer ────────────────
const map = new Map([["createdAt", new Date()]]);
JSON.stringify(map);
// '{}'  — Map is not serialized by default!

JSON.stringify(map, (key, value) => {
  if (value instanceof Map) {
    return Object.fromEntries(value);  // convert Map to plain object
  }
  return value;
});
// '{"createdAt":"2024-01-15T10:30:00.000Z"}'

A common gotcha: the replacer function receives date values after toJSON() has already been called, so by the time your replacer sees a date field, it is already a string — not a Date object. This meansvalue instanceof Date is always false inside a replacer. Check for the ISO string pattern instead, as shown above.Map and Set objects also require explicit handling in the replacer — they serialize as {} and[] by default, silently dropping all their contents.

JSON.parse() Reviver for Automatic Date Conversion

A reviver function passed to JSON.parse() is called bottom-up for every key-value pair in the parsed result. It receives the key and the already-parsed value, and its return value replaces the original. Returning undefineddeletes the key. Use a reviver to convert ISO 8601 strings to Date objects automatically, so consuming code never sees raw date strings. The regex is the critical safety gate — match too loosely and you convert version strings or other date-like strings; match too tightly and you miss valid ISO variants.

// ── ISO 8601 regex patterns ───────────────────────────────────────
// Strict: requires Z or explicit offset
const ISO_STRICT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;

// Lenient: accepts date-only strings too
const ISO_LENIENT = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;

// ── Basic date reviver ────────────────────────────────────────────
function dateReviver(key: string, value: unknown): unknown {
  if (typeof value === "string" && ISO_STRICT.test(value)) {
    return new Date(value);
  }
  return value;
}

const json = '{"id":1,"createdAt":"2024-01-15T10:30:00.000Z","name":"Alice"}';
const obj = JSON.parse(json, dateReviver);

console.log(obj.createdAt instanceof Date);  // true
console.log(obj.createdAt.getFullYear());    // 2024
console.log(obj.name);                       // "Alice" — untouched

// ── Reviver order: bottom-up (leaf nodes first) ───────────────────
const nested = JSON.parse(
  '{"user":{"name":"Alice","createdAt":"2024-01-15T10:30:00Z"},"tags":["a","b"]}',
  dateReviver
);
// nested.user.createdAt is a Date — reviver processes leaf before parent ✓

// ── Array of dates ────────────────────────────────────────────────
const arrayJson = '["2024-01-15T10:30:00Z","2024-02-01T00:00:00Z"]';
const dates = JSON.parse(arrayJson, dateReviver);
// [Date(2024-01-15), Date(2024-02-01)]  — ✓ works in arrays too

// ── Selective reviver: convert only specific field names ──────────
const DATE_FIELDS = new Set(["createdAt", "updatedAt", "deletedAt", "publishedAt"]);

function selectiveDateReviver(key: string, value: unknown): unknown {
  if (DATE_FIELDS.has(key) && typeof value === "string") {
    const d = new Date(value);
    return isNaN(d.getTime()) ? value : d;  // return original if invalid
  }
  return value;
}

// ── Reviver with Unix ms support ─────────────────────────────────
function flexibleDateReviver(key: string, value: unknown): unknown {
  // ISO string
  if (typeof value === "string" && ISO_STRICT.test(value)) {
    return new Date(value);
  }
  // Unix ms integer (13 digits, reasonable range: 2001–2286)
  if (typeof value === "number" && value > 1e12 && value < 1e13) {
    return new Date(value);
  }
  return value;
}

// ── TypeScript: typed parse with reviver ─────────────────────────
interface User {
  id: number;
  name: string;
  createdAt: Date;
}

function parseUser(json: string): User {
  return JSON.parse(json, dateReviver) as User;
}

const user = parseUser('{"id":1,"name":"Alice","createdAt":"2024-01-15T10:30:00Z"}');
user.createdAt.toLocaleDateString();  // works — it is a Date

The selective reviver (by field name) is safer for production use because it does not risk misidentifying strings like"2024-01-15" used as version numbers or date labels. The flexible reviver that handles both ISO strings and Unix milliseconds is useful when consuming APIs that are inconsistent in their date format — though fixing the API is preferable. Always validate the Date object after construction with isNaN(d.getTime()) — an invalid date string produces a Date object whose methods return NaN, not an exception.

Timezone Pitfalls in JSON Date Serialization

Timezone bugs are the most common source of off-by-one-day errors in date handling. The root cause: ISO 8601 strings without a timezone offset are interpreted as local time in some environments and as UTC in others, and JavaScript itself changed this behavior between ES5 and ES2015. Always include a timezone indicator in serialized dates. The safest rule: serialize all dates as UTC using toISOString(), store the user's timezone separately as an IANA timezone name, and reconstruct local times on display.

// ── The ambiguous no-offset string ───────────────────────────────
// ES2015+: date-only (YYYY-MM-DD) → UTC midnight
// ES2015+: date-time without offset (YYYY-MM-DDTHH:mm:ss) → LOCAL time
// This inconsistency is a JavaScript spec quirk — avoid both forms

new Date("2024-01-15");           // UTC midnight → Jan 15 in UTC ✓ (ES2015+)
new Date("2024-01-15T10:30:00"); // LOCAL time   → ambiguous ✗
new Date("2024-01-15T10:30:00Z"); // UTC explicit → always correct ✓

// ── Always use toISOString() for serialization ────────────────────
const date = new Date(2024, 0, 15, 10, 30, 0);  // local time Jan 15 10:30
date.toISOString();    // "2024-01-15T18:30:00.000Z" (if UTC-8)
date.toString();       // "Mon Jan 15 2024 10:30:00 GMT-0800 ..." ← NEVER use for JSON

// ── Pattern: store UTC + timezone name ───────────────────────────
interface TimezoneAwareDate {
  utc: string;        // ISO 8601 UTC — for arithmetic and storage
  timezone: string;   // IANA name — for display and local conversion
}

function serializeTzDate(date: Date, ianaTimezone: string): TimezoneAwareDate {
  return {
    utc: date.toISOString(),
    timezone: ianaTimezone,
  };
}

const appointment = serializeTzDate(
  new Date("2024-01-15T18:30:00Z"),
  "America/New_York"
);
// { utc: "2024-01-15T18:30:00.000Z", timezone: "America/New_York" }

// Reconstruct local time for display
function displayLocalTime(tzDate: TimezoneAwareDate): string {
  return new Intl.DateTimeFormat("en-US", {
    timeZone: tzDate.timezone,
    dateStyle: "full",
    timeStyle: "short",
  }).format(new Date(tzDate.utc));
}

displayLocalTime(appointment);
// "Monday, January 15, 2024 at 1:30 PM" (EST = UTC-5 in winter)

// ── Daylight saving time (DST) pitfall ───────────────────────────
// America/New_York is UTC-5 in winter (EST) and UTC-4 in summer (EDT)
// Always store UTC — never store a pre-computed local time in JSON

// ✗ Wrong: store local time "2024-07-04T12:00:00-04:00"
//   → ambiguous if the offset is wrong at the transition boundary

// ✓ Correct: store UTC + timezone name
// { utc: "2024-07-04T16:00:00Z", timezone: "America/New_York" }

// ── Comparing dates across timezones ─────────────────────────────
function datesAreSameDay(
  utcA: string,
  utcB: string,
  timezone: string
): boolean {
  const fmt = new Intl.DateTimeFormat("en-CA", {
    timeZone: timezone,
    year: "numeric", month: "2-digit", day: "2-digit",
  });
  return fmt.format(new Date(utcA)) === fmt.format(new Date(utcB));
}

datesAreSameDay(
  "2024-01-15T23:00:00Z",  // 6 PM EST
  "2024-01-16T03:00:00Z",  // 10 PM EST (different UTC date!)
  "America/New_York"
);
// true — same local day in New York despite different UTC dates

The Daylight Saving Time trap: storing a pre-computed offset like "-04:00" bakes in the summer offset — when this date is parsed later, the library may not know whether -04:00 was correct for that specific date and timezone. Storing the IANA timezone name ("America/New_York") lets the runtime compute the correct offset for any date, automatically handling DST transitions. The Intl.DateTimeFormat API with a timeZone option is the correct browser-native way to display dates in any timezone without additional libraries.

Working with date-fns and Luxon for JSON Date Processing

date-fns and Luxon are the two most popular JavaScript date libraries. Both work with ISO 8601 JSON dates but use different philosophies: date-fns provides pure functions that operate on native Date objects (zero overhead for existing code); Luxon wraps dates in an immutable DateTime object with timezone support built in. For JSON serialization, both libraries are transparent — JSON.stringify() still calls toJSON() on nativeDate objects, and Luxon's DateTime requires explicit serialization.

// ── date-fns: parse and format ISO dates ─────────────────────────
// npm install date-fns
import { parseISO, formatISO, isValid, addDays, differenceInDays } from "date-fns";

// Deserialize: ISO string → Date
const raw = "2024-01-15T10:30:00.000Z";
const date = parseISO(raw);            // Date object
isValid(date);                          // true
date.getFullYear();                     // 2024

// Serialize: Date → ISO string (for JSON)
const serialized = formatISO(date);    // "2024-01-15T10:30:00.000Z"
JSON.stringify({ updatedAt: date });   // uses toISOString() automatically ✓

// ── date-fns reviver ──────────────────────────────────────────────
import { parseISO, isValid } from "date-fns";

const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;

function dateFnsReviver(key: string, value: unknown): unknown {
  if (typeof value === "string" && ISO_RE.test(value)) {
    const d = parseISO(value);
    return isValid(d) ? d : value;  // guard against invalid dates
  }
  return value;
}

// ── date-fns arithmetic after JSON parse ─────────────────────────
const event = JSON.parse(
  '{"start":"2024-01-15T10:30:00Z","end":"2024-01-20T10:30:00Z"}',
  dateFnsReviver
);
const duration = differenceInDays(event.end, event.start);  // 5
const next = addDays(event.start, 7);                       // Date one week later

// ── Luxon: timezone-aware JSON handling ───────────────────────────
// npm install luxon  (+ @types/luxon for TypeScript)
import { DateTime } from "luxon";

// Deserialize: ISO string → Luxon DateTime
const iso = "2024-01-15T10:30:00.000Z";
const dt = DateTime.fromISO(iso, { zone: "utc" });
dt.isValid;                                       // true
dt.toFormat("yyyy-MM-dd HH:mm");                  // "2024-01-15 10:30"

// Serialize: Luxon DateTime → ISO string for JSON
const forJson = dt.toISO();   // "2024-01-15T10:30:00.000Z"
JSON.stringify({ createdAt: forJson });

// ── Luxon timezone conversion ─────────────────────────────────────
const utc = DateTime.fromISO("2024-01-15T18:30:00Z", { zone: "utc" });
const nyTime = utc.setZone("America/New_York");
nyTime.toISO();       // "2024-01-15T13:30:00.000-05:00"
nyTime.toFormat("h:mm a");  // "1:30 PM"

// ── Luxon with custom toJSON ──────────────────────────────────────
class CalendarEvent {
  title: string;
  start: DateTime;
  timezone: string;

  constructor(title: string, start: DateTime, timezone: string) {
    this.title = title;
    this.start = start;
    this.timezone = timezone;
  }

  toJSON() {
    return {
      title: this.title,
      start: this.start.toUTC().toISO(),  // always serialize as UTC
      timezone: this.timezone,
    };
  }

  static fromJSON(obj: { title: string; start: string; timezone: string }) {
    return new CalendarEvent(
      obj.title,
      DateTime.fromISO(obj.start, { zone: "utc" }),
      obj.timezone
    );
  }
}

const ev = new CalendarEvent("Team sync", DateTime.now().toUTC(), "Europe/London");
const json = JSON.stringify(ev);
const restored = CalendarEvent.fromJSON(JSON.parse(json));

date-fns' parseISO() is more lenient than new Date() — it follows the ISO 8601 standard strictly and handles edge cases like date-only strings consistently. The isValid() guard is essential becauseparseISO("not-a-date") returns an Invalid Date object rather than throwing. Luxon's DateTimerequires explicit serialization in toJSON() because it is not a native DateJSON.stringify()will not call toISO() automatically. The static fromJSON() pattern paired with toJSON()provides a clean serialize/deserialize boundary at the class level.

The Temporal API: Next-Generation Date Handling

The TC39 Temporal API reached Stage 4 and ships natively in Node.js 24+. It replaces the broken Date object with a set of distinct types for different temporal concepts: a point in time (always UTC), a calendar date, a wall-clock time, and a timezone-aware combined type. For JSON handling, the most relevant types are Temporal.Instant(UTC point in time, equivalent to a Date) and Temporal.ZonedDateTime (UTC + IANA timezone). Both serialize to ISO 8601 strings via .toString().

// ── Temporal.Instant: UTC point in time ──────────────────────────
// Node.js 24+ native; older versions: npm install @js-temporal/polyfill

// Deserialize ISO string → Temporal.Instant
const iso = "2024-01-15T10:30:00.000Z";
const instant = Temporal.Instant.from(iso);
instant.toString();  // "2024-01-15T10:30:00Z"
instant.epochMilliseconds;  // 1705312200000 (Unix ms)

// Serialize Temporal.Instant → JSON
JSON.stringify({ createdAt: instant.toString() });
// '{"createdAt":"2024-01-15T10:30:00Z"}'

// ── Temporal.PlainDate: date without time ─────────────────────────
const plain = Temporal.PlainDate.from("2024-01-15");
plain.toString();    // "2024-01-15"
plain.year;          // 2024
plain.month;         // 1 (1-indexed — unlike Date's 0-indexed months)
plain.day;           // 15

// ── Temporal.ZonedDateTime: date + time + timezone ────────────────
const zdt = Temporal.ZonedDateTime.from(
  "2024-01-15T10:30:00[America/New_York]"
);
zdt.toString();       // "2024-01-15T10:30:00-05:00[America/New_York]"
zdt.toInstant().toString();  // "2024-01-15T15:30:00Z" (UTC equivalent)

// ── Temporal reviver for JSON.parse() ────────────────────────────
const TEMPORAL_ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:?\d{2})/;

function temporalReviver(key: string, value: unknown): unknown {
  if (typeof value === "string" && TEMPORAL_ISO_RE.test(value)) {
    try {
      return Temporal.Instant.from(value);
    } catch {
      return value;
    }
  }
  return value;
}

const obj = JSON.parse(
  '{"id":1,"createdAt":"2024-01-15T10:30:00Z"}',
  temporalReviver
);
obj.createdAt instanceof Temporal.Instant;  // true

// ── Custom toJSON with Temporal types ────────────────────────────
class Article {
  title: string;
  publishedAt: Temporal.Instant;

  constructor(title: string, publishedAt: Temporal.Instant) {
    this.title = title;
    this.publishedAt = publishedAt;
  }

  toJSON() {
    return {
      title: this.title,
      publishedAt: this.publishedAt.toString(),  // "2024-01-15T10:30:00Z"
    };
  }
}

// ── Temporal arithmetic (no DST bugs) ────────────────────────────
const start = Temporal.Instant.from("2024-03-10T05:00:00Z");  // UTC

// Add 24 hours — always exactly 86400 seconds, DST-safe
const end = start.add({ hours: 24 });
end.toString();  // "2024-03-11T05:00:00Z"

// ZonedDateTime arithmetic respects DST (add 1 calendar day)
const zdtStart = Temporal.ZonedDateTime.from(
  "2024-03-10T00:00:00[America/New_York]"  // before spring-forward
);
const zdtNext = zdtStart.add({ days: 1 });
zdtNext.toString();  // "2024-03-11T00:00:00-04:00[America/New_York]" (only 23h elapsed)

The key advantage of Temporal over Date is type safety: you cannot accidentally pass a PlainDate where a ZonedDateTimeis expected, and there is no ambiguous "no-timezone" representation. The 1-indexed months in Temporal fix one of Date's most notorious bugs (January = 0). For Node.js versions below 24, @js-temporal/polyfill provides a production-ready implementation. The TC39 Temporal proposal documentation recommends Temporal.Instant for storage and interchange, and Temporal.ZonedDateTime for user-facing datetime operations that must respect timezone rules including DST.

TypeScript Types for JSON Dates

TypeScript cannot distinguish a string that contains an ISO date from any other string at the type level. Branded types and utility types close this gap, making the difference between a serialized date string and a runtimeDate object visible to the compiler. This prevents the most common bug: passing a date string to a function that expects a Date object, or calling .toISOString() on a string. See the TypeScript JSON types guide for broader patterns on typing JSON in TypeScript.

// ── Branded ISO string type ───────────────────────────────────────
type ISODateString = string & { readonly __brand: "ISODateString" };

function toISO(date: Date): ISODateString {
  return date.toISOString() as ISODateString;
}

function parseDate(iso: ISODateString): Date {
  return new Date(iso);
}

const iso = toISO(new Date());             // ISODateString
const date = parseDate(iso);               // Date
// parseDate("2024-01-15") would be a type error — string ≠ ISODateString

// ── Serialized<T> utility type ────────────────────────────────────
// Converts Date fields to string for the wire format (JSON payload)
type Serialized<T> = {
  [K in keyof T]: T[K] extends Date
    ? string
    : T[K] extends Date | undefined
    ? string | undefined
    : T[K] extends object
    ? Serialized<T[K]>
    : T[K];
};

// Runtime type (with Date objects)
interface User {
  id: number;
  name: string;
  createdAt: Date;
  profile: {
    bio: string;
    lastLogin: Date;
  };
}

// Wire format type (with string dates — what JSON.parse() returns)
type UserJson = Serialized<User>;
// {
//   id: number;
//   name: string;
//   createdAt: string;
//   profile: { bio: string; lastLogin: string };
// }

// ── Type-safe JSON parse ──────────────────────────────────────────
function parseUserJson(json: string): User {
  const raw = JSON.parse(json) as UserJson;  // strings, not Dates
  return {
    ...raw,
    createdAt: new Date(raw.createdAt),
    profile: {
      ...raw.profile,
      lastLogin: new Date(raw.profile.lastLogin),
    },
  };
}

// ── Zod schema for runtime validation + type inference ───────────
// npm install zod
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  createdAt: z.string().datetime().pipe(z.coerce.date()),
  // .datetime() validates ISO 8601 format
  // .coerce.date() converts the validated string to Date
});

type UserFromZod = z.infer<typeof UserSchema>;
// { id: number; name: string; createdAt: Date }

const user = UserSchema.parse(
  JSON.parse('{"id":1,"name":"Alice","createdAt":"2024-01-15T10:30:00Z"}')
);
user.createdAt instanceof Date;  // true ✓

// ── Discriminated union: ISO string vs Unix ms ────────────────────
type DateField =
  | { format: "iso"; value: string }    // "2024-01-15T10:30:00Z"
  | { format: "unix"; value: number };  // 1705312200000

function toDate(field: DateField): Date {
  if (field.format === "iso") return new Date(field.value);
  return new Date(field.value);  // getTime() already
}

The Serialized<T> utility type is the most practical pattern for teams that have established domain model types with Datefields — it automatically generates the corresponding JSON payload type without duplicating interface definitions. Zod's .datetime().pipe(z.coerce.date()) chain is the most ergonomic approach when you need both runtime validation and TypeScript type inference from a single source of truth. Branded types add the most value at API boundaries where strings from external sources must be validated before use.

Key Terms

ISO 8601
An international standard for representing dates and times as strings. The canonical form for JSON is"2024-01-15T10:30:00.000Z" — year, month, day, the letter T as separator, hours, minutes, seconds, optional milliseconds, and a timezone indicator (Z for UTC, or +HH:MM/-HH:MM for offsets). ISO 8601 strings are sortable lexicographically when all dates use the same timezone offset.JSON.stringify() always produces ISO 8601 with Z suffix via Date.prototype.toJSON(). Strings without a timezone offset are ambiguous — avoid them in JSON.
Unix timestamp
An integer representing the number of seconds (10 digits) or milliseconds (13 digits) elapsed since 1970-01-01T00:00:00Z (the Unix epoch). JavaScript's Date.getTime() returns milliseconds. Unix timestamps are always UTC by definition — there is no timezone ambiguity. Millisecond timestamps are larger than 1e12 for any date after 2001; second timestamps are smaller than 1e10 until 2286. Never mix seconds and milliseconds in the same system — multiplying by 1000 at the wrong place produces dates 56,000 years in the future.
reviver function
A callback function passed as the second argument to JSON.parse(key, value). It is called bottom-up (leaf nodes first) for every key-value pair in the parsed JSON. The return value of the reviver replaces the original value; returning undefined deletes the key. Reviver functions are used to convert ISO 8601 date strings into Date objects automatically during parsing, eliminating the need for post-processing. The reviver receives values after any toJSON() transformation — so dates arrive as strings, notDate instances. A regex guard in the reviver prevents accidentally converting non-date strings that happen to match the date format.
toJSON()
A method that JSON.stringify() calls on any object that defines it before serializing that object.Date.prototype.toJSON() returns the same string as Date.prototype.toISOString() — UTC ISO 8601 with milliseconds and Z suffix. Custom classes can define toJSON() to control their JSON representation: returning a plain object, a string, a number, or any other JSON-serializable value.toJSON() is called before the replacer function, so the replacer sees the toJSON() output rather than the original object.
timezone offset
A fixed offset from UTC expressed as +HH:MM or -HH:MM in an ISO 8601 string. For example,"2024-01-15T10:30:00-05:00" means 10:30 AM in a timezone 5 hours behind UTC (UTC-5, i.e., EST). A timezone offset is distinct from an IANA timezone name ("America/New_York") — the offset is fixed, while the IANA name encodes DST rules and changes the offset seasonally. Storing an offset instead of an IANA name bakes in the wrong offset during DST transitions. Always prefer storing the IANA timezone name for future-proof serialization.
UTC
Coordinated Universal Time — the primary time standard by which the world regulates clocks. UTC has no DST offset and serves as the reference point for all timezone offsets. In ISO 8601, UTC is indicated by the Z suffix (shorthand for +00:00). Date.prototype.toISOString() always returns UTC. Unix timestamps are always UTC by definition. For JSON date serialization, storing dates as UTC and converting to local time only on display is the recommended approach — it avoids DST bugs and makes cross-timezone comparisons correct.
Temporal API
The TC39 Temporal proposal — a modern replacement for JavaScript's Date object, reaching Stage 4 and available natively in Node.js 24+. Temporal provides distinct types for different temporal concepts:Temporal.Instant (a UTC point in time), Temporal.PlainDate (a calendar date without time),Temporal.PlainDateTime (date and time without timezone), and Temporal.ZonedDateTime(date, time, and IANA timezone). Key improvements over Date: immutable objects, 1-indexed months, explicit timezone handling, nanosecond precision, and a proper duration type. For JSON, Temporal.Instant.from(isoString)deserializes and instant.toString() serializes back to ISO 8601.

FAQ

How does JSON.stringify() handle Date objects?

JSON.stringify() automatically calls Date.prototype.toJSON() on every Date value it encounters, which returns an ISO 8601 UTC string identical to .toISOString():"2024-01-15T10:30:00.000Z". This conversion is automatic and lossless — no precision is lost and theZ suffix makes the UTC timezone explicit. For JSON.stringify(new Date()) the output is a quoted string like '"2024-01-15T10:30:00.000Z"'. Nested dates in plain objects, arrays, and class instances are also converted automatically as long as the property is enumerable. The important exception: Date objects stored in Map or Set are not serialized automatically — you must use a replacer function to convert Map to a plain object first. You can customize per-object serialization by defining atoJSON() method on your class that returns a Unix timestamp integer instead of an ISO string.

How do I parse JSON date strings back into Date objects?

JSON.parse() returns date strings as plain string values — it never automatically converts them to Date objects. You have three options. First, use a reviver function as the second argument:JSON.parse(json, (key, value) => typeof value === "string" && /^\d4-\d2-\d2T/.test(value) ? new Date(value) : value) — this converts every ISO-looking string during parsing and works correctly for deeply nested objects. Second, post-process specific fields after parsing: const obj = JSON.parse(json); obj.createdAt = new Date(obj.createdAt) — this is explicit and safe when you know the schema. Third, use a validation library like Zod withz.string().datetime().pipe(z.coerce.date()) to validate and convert simultaneously. Always verify the resulting Date object is valid with !isNaN(date.getTime()) — an invalid ISO string produces an Invalid Date object, not an exception.

What is the difference between ISO 8601 and Unix timestamp in JSON?

ISO 8601 encodes dates as human-readable strings ("2024-01-15T10:30:00.000Z", 24+ characters) while Unix timestamps encode them as integers — milliseconds (1705312200000, 13 digits) or seconds (1705312200, 10 digits) since 1970-01-01T00:00:00Z. ISO 8601 advantages: human-readable without decoding tools, self-documenting with explicit timezone, lexicographically sortable for UTC strings, standard across all languages. Unix timestamp advantages: compact (8 bytes as a 64-bit integer vs 24 bytes as a string), trivially comparable and sortable as numbers, no parsing required for arithmetic, no timezone ambiguity since always UTC. The critical rule: never mix 10-digit seconds and 13-digit milliseconds in the same system — the error is silent and produces dates off by a factor of 1000 (year 56,000 instead of 2024). A heuristic check: timestamps greater than 1e12are milliseconds; smaller values are seconds.

How do I handle timezones correctly in JSON date serialization?

Follow three rules. First, always serialize as UTC using date.toISOString() — it always produces aZ-suffixed UTC string regardless of the local system timezone. Never use .toString() or.toLocaleString() for JSON serialization. Second, avoid ISO 8601 strings without a timezone indicator:"2024-01-15T10:30:00" without Zor an offset is ambiguous — JavaScript ES2015+ treats date-only strings as UTC midnight but date-time strings without offset as local time. Third, if you need to preserve the user's original timezone (e.g., for appointment display), store the IANA timezone name alongside the UTC timestamp:{ "utc": "2024-01-15T18:30:00Z", "timezone": "America/New_York" }. Reconstruct the local time on display using Intl.DateTimeFormat with the stored timezone. Storing a pre-computed offset (-05:00) instead of the IANA name is fragile — it does not account for DST transitions and may be wrong for dates in different seasons.

How do I use a JSON.parse() reviver to convert date strings automatically?

Pass a reviver function as the second argument to JSON.parse(). The reviver is called for every key-value pair from leaf nodes up to the root. Use a regex to identify ISO 8601 strings and convert them:const ISO_RE = /^\d4-\d2-\d2T\d2:\d2:\d2(\.\d+)?(Z|[+-]\d2:?\d2)$/;then in the reviver: if (typeof value === "string" && ISO_RE.test(value)) return new Date(value);. The regex is important — a loose check like value.includes("T") would falsely convert strings like"Title" or "button". An alternative is a selective reviver that only converts known field names (e.g., createdAt, updatedAt) — safer when the JSON schema is fixed. Always validate the constructed Date: const d = new Date(value); if (!isNaN(d.getTime())) return d; — this prevents returning an Invalid Date for strings that match the regex but contain invalid date values.

What is the TC39 Temporal API and how does it improve JSON date handling?

The TC39 Temporal API is a modern replacement for JavaScript's Date object, reaching Stage 4 and available natively in Node.js 24+ (polyfill: npm install @js-temporal/polyfill). It fixes coreDate problems: mutable objects (Temporal is immutable), 0-indexed months (Temporal is 1-indexed), no timezone-aware arithmetic, no duration type, and confusing local/UTC method names. For JSON, the most relevant type is Temporal.Instant — a UTC point in time. Deserialize: Temporal.Instant.from("2024-01-15T10:30:00Z"). Serialize: instant.toString() produces "2024-01-15T10:30:00Z". For timezone-aware dates,Temporal.ZonedDateTime stores the UTC time plus an IANA timezone name as a first-class value. The key JSON workflow improvement: Temporal types reject ambiguous inputs at construction time rather than silently producing incorrect results, making parsing bugs fail fast and visibly.

How do I serialize and deserialize dates with date-fns?

date-fns works with native Date objects, so serialization is automatic via JSON.stringify()(which calls .toJSON()). For deserialization, use parseISO() from date-fns rather thannew Date() — it handles ISO 8601 strings more strictly and consistently across environments. Pattern: import { parseISO, isValid } from "date-fns"; const d = parseISO(json.createdAt); if (!isValid(d)) throw new Error("Invalid date");. For a JSON.parse() reviver with date-fns: check the string with a regex, call parseISO(value), then verify with isValid(d) before returning — this prevents returning an Invalid Date for strings that look date-like but contain impossible values (e.g., "2024-13-45T99:99:99Z"). For formatting dates before serialization: formatISO(date) produces "2024-01-15T10:30:00+00:00". date-fns does not have a built-in timezone conversion utility — use date-fns-tz for IANA timezone support.

How do I type JSON date fields in TypeScript?

Three levels of type safety, in increasing rigor. Basic: use string for wire-format fields and Datefor runtime fields — simple but allows passing any string where a date string is expected. Branded string:type ISODateString = string & { readonly __brand: "ISODateString" } — wrap toISOString()output in a casting function that returns ISODateString, making it a type error to pass an unvalidated string. Utility type: type Serialized<T> that recursively replaces Date withstring — generates the wire format type from your runtime type automatically without duplication. For runtime validation and type inference together, use Zod:z.string().datetime().pipe(z.coerce.date()) validates the ISO 8601 format and converts toDate in one step, with z.infer giving you a TypeScript type where the field is Date. For Temporal API types, annotate fields as Temporal.Instant and serialize with .toString(), deserialize with Temporal.Instant.from().

Further reading and primary sources

  • MDN: Date.prototype.toJSON()MDN reference for Date.prototype.toJSON() — how JSON.stringify() converts Date objects to ISO 8601 strings
  • MDN: JSON.parse() reviverMDN documentation for the JSON.parse() reviver parameter with examples
  • TC39 Temporal API proposalOfficial Temporal API documentation — Temporal.Instant, ZonedDateTime, PlainDate, and JSON serialization patterns
  • date-fns parseISOdate-fns API reference for parseISO() — parses ISO 8601 date strings into Date objects
  • ISO 8601 — WikipediaComprehensive reference for the ISO 8601 date and time standard, including all valid formats