Two objects can look similar and still mean different things.
In memory, a booking can use every tool its language gives it. A Date has
methods and a timezone representation. A Map knows its entries are key/value
pairs. A bigint keeps integer digits beyond JavaScript’s safe-number range. An absent
property can mean “the caller never supplied a coupon,” while null can mean “the
caller explicitly cleared it.”
JSON has objects, arrays, strings, numbers, booleans, and null. That small set
is a strength for interoperability, but it means the boundary must decide how richer values
become wire values—and how a consumer knows what those values mean later.
Before serializing, ask which distinctions the receiver needs and which representation makes them explicit.
Read the default round tripsTypeScript · Date, Map, and BigInt do not behave alike
export function defaultDateRoundTrip(date: Date) {
const wire = JSON.stringify({ checkIn: date });
return { wire, after: JSON.parse(wire).checkIn } as const;
}
export function defaultMapRoundTrip(notes: Map<string, string>) {
const wire = JSON.stringify({ notes });
return { wire, after: JSON.parse(wire).notes } as const;
}
export function defaultBigIntRoundTrip(amount: bigint) {
try {
return { wire: JSON.stringify({ depositCents: amount }), after: 'unreachable' } as const;
} catch (error) {
return {
wire: null,
after: error instanceof TypeError ? 'BigInt cannot be serialized' : 'unknown failure'
} as const;
}
} The Date becomes ISO text, which can be a good wire choice if it is documented. The Map
becomes an empty object because its entries are not enumerable object properties. BigInt
makes JSON.stringify throw. None of these outcomes is a complete booking contract.
Default JSON, a wire contract, and a custom codec make different promises.
Default JSON is a useful baseline. An explicit wire contract maps each richer value to a named primitive or collection and says what the consumer should do. A custom codec can restore runtime identity, but then its tags, decoder, allow-list, and versioning become part of the contract.
Default JSON
Let the serializer decide what the runtime value looks like.
- Good at
- Simple data with explicit JSON primitives.
- Risk
- Silent loss, rounding, or a thrown serialization.
Wire contract
Map Date, amounts, entries, and states to deliberate wire values.
- Good at
- Inspectable, portable public APIs.
- Risk
- Consumers must follow the documented interpretation.
Custom codec
Add tags and revival when preserving runtime identity is genuinely useful.
- Good at
- Rich local snapshots and controlled storage.
- Risk
- Decoder safety, compatibility, and more machinery.
| Value | Default JSON | Explicit wire | Question to answer |
|---|---|---|---|
| Missing / null | Undefined property is omitted | Tagged state or documented omission | Does absent mean something different from cleared? |
| Date | ISO string, not Date | ISO string by contract | Which timezone and precision are promised? |
| Large integer | BigInt throws; number may round | Decimal string or tagged value | Who owns precision and conversion? |
| Map | Often becomes an empty object | Object or entry list | Do keys, order, and duplicates matter? |
Read the explicit codecTypeScript · map rich values to inspectable wire data
export function encodeCoupon(coupon: BookingSnapshot['coupon']): CouponWire {
if (coupon === undefined) return { kind: 'missing' };
if (coupon === null) return { kind: 'none' };
return { kind: 'value', value: coupon };
}
// Notes travel as [key, value] pairs sorted by key, so every encoder writes the same bytes.
export function encodeBooking(snapshot: BookingSnapshot): BookingWire {
return {
id: snapshot.id,
checkIn: snapshot.checkIn.toISOString(),
depositCents: snapshot.depositCents.toString(),
notes: [...snapshot.notes.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
coupon: encodeCoupon(snapshot.coupon)
};
}
// The wire format is exactly what toISOString writes, so a real date survives the round trip
// and a lookalike such as "2026-13-45T00:00:00.000Z" does not.
function decodeCheckIn(text: string): Date {
const date = new Date(text);
if (Number.isNaN(date.valueOf()) || date.toISOString() !== text) {
throw new Error('invalid check-in');
}
return date;
}
export function decodeBooking(wire: BookingWire): BookingSnapshot {
const checkIn = decodeCheckIn(wire.checkIn);
if (!/^\d+$/.test(wire.depositCents)) throw new Error('invalid deposit');
const coupon =
wire.coupon.kind === 'missing'
? undefined
: wire.coupon.kind === 'none'
? null
: wire.coupon.value;
return {
id: wire.id,
checkIn,
depositCents: BigInt(wire.depositCents),
notes: new Map(wire.notes),
coupon
};
} The codec does not ask JSON to understand a Date or BigInt. It first creates a BookingWire: ISO text, decimal text, entry tuples, and a tagged coupon state.
The decoder validates those choices before reconstructing local values.
Change one representation at a time.
Choose a hazard and a strategy. Watch the value before the boundary, the bytes or JSON-shaped wire, and the value after parsing. Notice that “preserved” can mean a deliberate wire type, not an automatic recreation of the original runtime object.
Change the value and the wire policy.
checkIn: Date
{"checkIn":"2026-09-20T00:00:00.000Z"}
checkIn: string
lost
Watch for Parsing JSON recreates JSON primitives, not the original Date, Map, or presence semantics.
Read the Go wire mappingGo · time, int64, maps, and coupon states need different policies
func encodeCoupon(coupon Coupon) CouponWire {
switch coupon.State {
case CouponNone:
return CouponWire{Kind: "none"}
case CouponValue:
return CouponWire{Kind: "value", Value: coupon.Code}
default:
return CouponWire{Kind: "missing"}
}
}
// Notes travel as [key, value] pairs sorted by key, so every encoder writes the same bytes.
func encodeBooking(snapshot BookingSnapshot) BookingWire {
keys := make([]string, 0, len(snapshot.Notes))
for key := range snapshot.Notes {
keys = append(keys, key)
}
sort.Strings(keys)
notes := make([][2]string, 0, len(keys))
for _, key := range keys {
notes = append(notes, [2]string{key, snapshot.Notes[key]})
}
return BookingWire{
ID: snapshot.ID,
CheckIn: snapshot.CheckIn.UTC().Format(checkInLayout),
DepositCents: fmt.Sprintf("%d", snapshot.DepositCents),
Notes: notes,
Coupon: encodeCoupon(snapshot.Coupon),
}
}
func decodeCoupon(wire CouponWire) (Coupon, error) {
switch wire.Kind {
case "missing":
return Coupon{State: CouponMissing}, nil
case "none":
return Coupon{State: CouponNone}, nil
case "value":
return Coupon{State: CouponValue, Code: wire.Value}, nil
}
return Coupon{}, fmt.Errorf("unknown coupon kind %q", wire.Kind)
}
func decodeBooking(wire BookingWire) (BookingSnapshot, error) {
checkIn, err := time.Parse(checkInLayout, wire.CheckIn)
if err != nil || checkIn.Format(checkInLayout) != wire.CheckIn {
return BookingSnapshot{}, fmt.Errorf("invalid check-in %q", wire.CheckIn)
}
var deposit int64
if _, err := fmt.Sscanf(wire.DepositCents, "%d", &deposit); err != nil {
return BookingSnapshot{}, err
}
notes := make(map[string]string, len(wire.Notes))
for _, note := range wire.Notes {
notes[note[0]] = note[1]
}
coupon, err := decodeCoupon(wire.Coupon)
if err != nil {
return BookingSnapshot{}, err
}
return BookingSnapshot{ID: wire.ID, CheckIn: checkIn, DepositCents: deposit, Notes: notes, Coupon: coupon}, nil
} Go’s standard library can marshal time.Time and int64, but the
resulting JSON still needs a cross-language contract. A JavaScript consumer cannot safely
treat every large integer as a number. A nil pointer also does not tell you
whether a coupon key was absent or explicitly null, so the snapshot carries a three-state Coupon and the wire type writes missing, none, or value. Both encoders write the same bytes: ISO text
with milliseconds, a decimal string, and notes as [key, value] pairs sorted by
key.
Choose what the receiver can safely recover.
Make the wire choice before reaching for a serializer option.
Keep a mapping layer between runtime and wire.
A mapper gives one place to decide what crosses the boundary. It can turn a Date into an ISO string, cents into decimal text, and a Map into entries. It can also reject an invalid wire value without letting a half-revived object reach booking logic.
Test both directions with examples that include empty, missing, null, boundary-size, and malformed values. A successful encode test is not enough: the important evidence is what the consumer sees and what the decoder refuses.
Default JSON round trips a Date, Map, and BigInt, showing what changes or fails.
export function defaultDateRoundTrip(date: Date) {
const wire = JSON.stringify({ checkIn: date });
return { wire, after: JSON.parse(wire).checkIn } as const;
}
export function defaultMapRoundTrip(notes: Map<string, string>) {
const wire = JSON.stringify({ notes });
return { wire, after: JSON.parse(wire).notes } as const;
}
export function defaultBigIntRoundTrip(amount: bigint) {
try {
return { wire: JSON.stringify({ depositCents: amount }), after: 'unreachable' } as const;
} catch (error) {
return {
wire: null,
after: error instanceof TypeError ? 'BigInt cannot be serialized' : 'unknown failure'
} as const;
}
} func defaultRoundTrip(snapshot BookingSnapshot) ([]byte, error) {
return json.Marshal(snapshot)
}
// A *string cannot tell a missing coupon from a cleared one: both decode to nil.
func decodeDefaultCoupon(body []byte) (*string, error) {
var payload struct {
Coupon *string `json:"coupon"`
}
err := json.Unmarshal(body, &payload)
return payload.Coupon, err
} - Portable field types
- Date and number representation
- Presence and omission rules
- Validation before revival
- Allowed tags and versions
- Clear decode failures
- Booking invariants
- Money and date meaning
- What a restored value is allowed to do
Parsed JSON does not gain methods by assertion.
Build frontends?Every response.json() in a component is the end of a round trip.
Where it already is in your components
A booking page that calls response.json() receives strings, numbers, arrays,
and plain objects, nothing else. The Date the server held is now text, and a type
annotation on the result does not change that.
When you have to own it
When the component formats a date, sums a large amount, or reads a map of notes, own the
decode step. The textbook component receives a codec and uses it before calling toLocaleDateString or reading a BigInt. The wild version casts the parsed body
into a Booking and compiles a runtime mismatch into the render path.
Decode the wire representation before the component uses Date, bigint, or Map behavior.
import { useEffect, useState } from 'react';
type Booking = { id: string; checkIn: Date; depositCents: bigint; notes: Map<string, string> };
type BookingWire = { id: string; checkIn: string; depositCents: string; notes: [string, string][] };
type BookingCodec = { decode(wire: unknown): Booking };
export function BookingSummary({ codec, url }: { codec: BookingCodec; url: string }) {
const [booking, setBooking] = useState<Booking | null>(null);
useEffect(() => {
let active = true;
void fetch(url)
.then((response) => response.json() as Promise<BookingWire>)
.then((wire) => {
const decoded = codec.decode(wire);
if (active) setBooking(decoded);
});
return () => {
active = false;
};
}, [codec, url]);
if (!booking) return <p>Loading…</p>;
return (
<p>
<strong>{booking.id}</strong> · {booking.checkIn.toLocaleDateString()} ·{' '}
{booking.depositCents.toString()} cents
</p>
);
}
Most serialization bugs are unstated decisions.
Numbers have a range
Document precision and use text or a tagged form when another runtime cannot represent the integer exactly.
Dates have a zone
Choose instant, calendar date, offset, precision, and whether the consumer receives text or a revived object.
Presence is data
Missing, null, empty, and default are different only if the contract says they are different.
Revival is executable
A custom decoder needs an allow-list and validation. Never let wire tags choose arbitrary constructors.
Use the simplest wire that keeps the meaning.
Default JSON is a good fit for values already made of strings, numbers within a documented range, booleans, arrays, objects, and null. Use an explicit wire contract when a Date, amount, presence state, or collection needs a meaning the default serializer cannot provide.
Reach for a custom codec when restoring runtime identity pays for its validation and lifetime. For public APIs, a boring representation is often easier to version across languages. For local snapshots, a codec may be worthwhile, but it is still a contract, not magic.
Keep this questionAsk it before a value leaves the process.
Which distinctions in this value must survive the wire, and which decoder refuses a value that lost them?
Every serialized value is part of a boundary.
When a value crosses a boundary, choose what survives before the serializer chooses for you.
Connections to follow nextRelated lessons
- API contracts names the agreement between a provider and consumer.
- Validation at the edge checks the wire before the application trusts it.
- Domain model vs DTO keeps runtime meaning separate from the external shape.
- Versioning and compatibility asks how these wire choices behave over time.
- Why
- Default JSON turns a Date into text, a Map into
{}, and refuses a BigInt. - What
- An explicit wire: ISO text for the date, decimal text for the deposit, sorted entry pairs for notes, and a tagged coupon state.
- Constraint
- TypeScript and Go must write and read the same bytes.
- Fallback
- The decoder rejects an invalid date, amount, or tag instead of reviving it.
- Reconsider when
- Every value is already a portable primitive, or a local snapshot needs runtime identity restored.