Serialize Temporal Values to JSON in JavaScript
To round-trip Temporal values through JSON, write with toString()/toJSON and read with a reviver that calls Temporal.*.from(). Part of Serializing and Storing Temporal Values.
Why this scenario is tricky
The asymmetry is worth stating precisely because it is the whole problem. Serialisation is a total function — any Temporal value has one canonical string — but deserialisation is not automatic, because a string on its own does not announce which type it should become. '2024-03-15' could be a PlainDate; '2024-03-15T09:00-04:00[America/New_York]' is clearly a ZonedDateTime; but JSON does not carry that intent, so the receiver must supply it.
This is exactly why a reviver is not an optional nicety but the missing half of the contract. Every place that parses date-bearing JSON without one is a place where typed values silently degrade to strings, and the failure surfaces at the first method call, far from the parse.
JSON has exactly one built-in convenience for dates, and it is for the wrong type. JSON.stringify special-cases Date by calling its toISOString, so a Date serialises to a string automatically — but there is no matching magic on the way back, so JSON.parse hands you a plain string that everyone forgets to turn back into a date. Temporal types do not even get the outbound convenience for free unless they implement toJSON, and none of them get inbound revival, because JSON.parse has no idea that a particular string was meant to become a Temporal.Instant. The result is an asymmetry that bites in the same place every time: values look correct in the serialised payload, survive the network, and then arrive on the other side as strings that throw the first time someone calls .add() on them.
JSON.stringify has built-in special handling for Date (it calls toISOString), but no knowledge of Temporal types. Left alone, a ZonedDateTime serializes via its toJSON to a string on the way out — good — but JSON.parse has no idea it should become a Temporal value again, so it silently stays a string and the next .add() call throws.
Minimal working solution
Keep the reviver close to the parse it belongs to, ideally wrapping both in a single helper so no caller can JSON.parse date-bearing data without rehydration. A parseWithTemporal(json) that always applies the reviver removes the discipline problem entirely, because there is no bare parse to forget.
The outbound half is easy because most Temporal types implement toJSON, so they serialise to their canonical ISO string with no extra work — a ZonedDateTime becomes '2024-03-15T09:00:00-04:00[America/New_York]', a PlainDate becomes '2024-03-15'. The inbound half is where you supply the missing piece: pass a reviver function to JSON.parse that recognises the keys you know hold Temporal values and calls the matching Temporal.*.from() on them. The reviver runs for every key/value pair as the object is rebuilt, so a small switch on the key name is enough to rehydrate a known shape, turning the strings back into typed values before any consumer touches them.
import { Temporal } from '@js-temporal/polyfill';
const json = JSON.stringify({ at: zdt }); // zdt.toJSON() → ISO string
const obj = JSON.parse(json, (k, v) =>
k === 'at' ? Temporal.ZonedDateTime.from(v) : v); // reviver rehydrates
Full production version
The tagged-value approach also gives you a natural place to version the format, which matters more than it first appears. A payload that outlives the code that wrote it — a queued message, a stored document, a cached blob — may be read back by a newer deployment, and a tag that includes a small format version lets the reviver handle both the old and new shapes during a migration. Without a discriminator you are left sniffing string patterns to guess a value's type, which is brittle and ambiguous the moment two types can produce similar-looking strings. The few extra bytes of an explicit tag buy you a format that is self-describing, versionable, and safe to evolve.
Keying a reviver on field names works for a fixed schema but does not scale to arbitrary payloads, so the robust pattern is to make each value self-describing. Serialise a Temporal value as a small tagged object — a type discriminator plus its ISO string — and write one reviver that reads the tag and dispatches to the correct from(). That way a single reviver rehydrates instants, zoned datetimes, and plain dates anywhere they appear in a nested structure, without the parser needing to know the schema in advance. The tag also future-proofs the format: if you later add durations or plain times, you extend the dispatch table rather than hunting down every call site that parses a particular field.
Tag values with their type so one reviver handles a whole payload.
import { Temporal } from '@js-temporal/polyfill';
const REVIVE: Record<string, (s: string) => unknown> = {
Instant: (s) => Temporal.Instant.from(s),
ZonedDateTime: (s) => Temporal.ZonedDateTime.from(s),
PlainDate: (s) => Temporal.PlainDate.from(s),
};
function reviver(_k: string, v: any) {
return v && typeof v === 'object' && v.__t in REVIVE ? REVIVE[v.__t](v.value) : v;
}
Verification snippet
Running these round-trip and shape assertions in the shared serialisation module means every service that speaks this format is held to the same contract, so a change to one side cannot silently diverge from the other.
It is also worth asserting the shape of the serialised output, not just the parsed input, because the two must agree. Snapshot the JSON a value produces and confirm it is the canonical ISO form you expect, so a future change to a toJSON implementation cannot silently alter the wire format that other services depend on.
For the tagged format, include a test that an unknown tag is handled gracefully — passed through untouched rather than throwing — so that adding a new type in one service does not break an older reviver that has not learned about it yet.
The test that matters is the full round-trip: take a value, JSON.stringify it, JSON.parse it back through your reviver, and assert the result is not just equal in string form but is an actual Temporal instance that still supports arithmetic. Assert that a parse without the reviver leaves the value as a string, so the test documents why the reviver is necessary. For the tagged format, round-trip an object containing several different Temporal types at once and confirm each comes back as its own type. A useful negative case is a malformed ISO string inside a tagged value, which should surface as a RangeError from from() rather than a silently broken object.
Common pitfalls
In one sentence: serialise Temporal values with toString/toJSON, always parse date-bearing JSON through a reviver that calls the matching from, tag values that share a payload so a single reviver can rehydrate any of them, keep the IANA zone in the string when the zone must survive, and treat the serialised shape as a versioned contract between producer and consumer. The round-trip is only correct when you own both halves of it.
A practical failure mode worth planning for is schema drift between services. If one service starts emitting a ZonedDateTime where another expects a PlainDate, an untagged format quietly mis-parses; a tagged format makes the mismatch explicit and catchable. Treat the serialised shape as a contract, version it, and validate incoming payloads against it rather than trusting that every producer agrees with every consumer forever.
A subtler pitfall is relying on Date's automatic toJSON behaviour as a mental model for Temporal and then being surprised that the inbound side is different. Date gets outbound serialisation for free from the JSON spec, but even Date has no automatic revival — the asymmetry has always been there; Temporal just makes it explicit by not hiding the outbound half either. Treating serialisation and revival as a matched pair that you own, rather than something the platform does for you, is the mindset that keeps round-trips correct. Write the reviver at the same time as the serialiser, test them together, and never let a JSON.parse of date-bearing data ship without one.
The dominant pitfall is calling JSON.parse without a reviver and assuming the Temporal values came back typed — they came back as strings, and the failure appears later and far away. The second is mixing Date and Temporal in the same payload, so half the fields auto-revive via Date habits and half do not, which is confusing to maintain; pick one representation. The third is embedding a raw offset like -04:00 without the [Zone] suffix for values that need to survive a future DST-rule change, since an offset alone cannot reconstruct the zone. Serialise with toString/toJSON, revive with from, tag values that share a payload, and keep the IANA zone in the string when the zone matters.
Frequently Asked Questions
Does JSON.stringify work on Temporal values?
Yes on the way out — most Temporal types implement toJSON, so they serialize to their canonical ISO string. The gap is on the way in: JSON.parse returns those as plain strings, so you must pass a reviver that calls Temporal.*.from() to rebuild the typed values.
How do I revive many different Temporal types from one payload?
Tag each serialized value with its type name and value, then use a single reviver that looks up the tag and calls the matching from(). This handles Instant, ZonedDateTime, and PlainDate in the same object without per-key special cases.
Does JSON.stringify work on Temporal values?
On the way out, yes for most types, because they implement toJSON and serialise to their canonical ISO string. The gap is on the way in: JSON.parse returns those as plain strings, so you must pass a reviver that calls Temporal.*.from() to rebuild the typed values. Stringify is only half the round-trip.
How do I revive many different Temporal types from one payload?
Serialise each value as a small tagged object carrying a type discriminator and its ISO string, then write one reviver that reads the tag and dispatches to the matching from(). That handles Instant, ZonedDateTime, and PlainDate anywhere in a nested structure without the parser needing to know the schema, and it extends cleanly when you add new types.
Why did my parsed date lose its time zone?
Because it was serialised with only a numeric offset rather than the full ZonedDateTime string that includes the [IANA/Zone] suffix. An offset like -04:00 records what the offset was, not which zone produced it, so it cannot survive a future DST-rule change. Serialise ZonedDateTime with toString so the bracketed zone travels with the value.