← Concepts & practices
Pattern Boundaries and contracts

Serialization hazards

The wire is not your runtime.

A booking service keeps a snapshot with a Date, a bigint deposit, a Map of notes, and a difference between a missing coupon and a cleared coupon. Send it through JSON and the value crosses a boundary into a much smaller vocabulary. Some distinctions become strings or empty objects. One value refuses to cross at all. The wire needs a deliberate representation before a consumer can safely rebuild meaning.

The judgment to keep

Serialization is a translation, not a teleport. Choose wire primitives, document the lost or preserved distinctions, validate before revival, and treat custom codecs as part of the public compatibility and security surface.

TypeScriptGo One booking snapshot · four round-trip hazards
Start with the round trip

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
booking.ts · default JSON
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.

Name the representations

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.

Choice 01

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.
Choice 02

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.
Choice 03

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.
What crosses the booking boundary
ValueDefault JSONExplicit wireQuestion to answer
Missing / nullUndefined property is omittedTagged state or documented omissionDoes absent mean something different from cleared?
DateISO string, not DateISO string by contractWhich timezone and precision are promised?
Large integerBigInt throws; number may roundDecimal string or tagged valueWho owns precision and conversion?
MapOften becomes an empty objectObject or entry listDo keys, order, and duplicates matter?
Read the explicit codecTypeScript · map rich values to inspectable wire data
booking.ts · explicit codec
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.

Follow the booking

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.

Booking snapshot

Change the value and the wire policy.

Runs a local round-trip model
Default JSON round tripDate value
01Application value enters JSON.stringify
02{"checkIn":"2026-09-20T00:00:00.000Z"}
03JSON.parse recreates only wire primitives
04checkIn: string
Before

checkIn: Date

On the wire

{"checkIn":"2026-09-20T00:00:00.000Z"}

After parse

checkIn: string

Outcome

lost

JSON carries objects, arrays, strings, numbers, booleans, and null—not the meaning of your runtime values.

Watch for Parsing JSON recreates JSON primitives, not the original Date, Map, or presence semantics.

The controls change a local model; they do not send a booking or run a custom decoder.
Read the Go wire mappingGo · time, int64, maps, and coupon states need different policies
booking.go · explicit wire
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.

Practice the boundary

Choose what the receiver can safely recover.

Make the wire choice before reaching for a serializer option.

A Date is stringified and parsed. What does the consumer receive?
A BigInt is larger than JavaScript’s safe integer range. Which wire form is safest?
A Map is sent through default JSON. What should you expect?
What should a custom decoder do with an unknown type tag?
Feedback stays on this page; it is not saved.
Put it in a service

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.

TypeScriptReading
booking.ts
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;
	}
}
GoAlongside
booking.go
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
}
Wire owns
  • Portable field types
  • Date and number representation
  • Presence and omission rules
Codec owns
  • Validation before revival
  • Allowed tags and versions
  • Clear decode failures
Domain owns
  • Booking invariants
  • Money and date meaning
  • What a restored value is allowed to do
Recognize it in UI code

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.

ReactAlready in your code
textbook.tsx · decode before render
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>
	);
}
Keep the wire honest

Most serialization bugs are unstated decisions.

01

Numbers have a range

Document precision and use text or a tagged form when another runtime cannot represent the integer exactly.

02

Dates have a zone

Choose instant, calendar date, offset, precision, and whether the consumer receives text or a revived object.

03

Presence is data

Missing, null, empty, and default are different only if the contract says they are different.

04

Revival is executable

A custom decoder needs an allow-list and validation. Never let wire tags choose arbitrary constructors.

Make the call

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?

Take the idea with you

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
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.