Docs / Packages / @zudojs/serialization
v1.2.1

@zudojs/serialization

Turns JavaScript values into text you can store or send, and turns that text back into the same values — including Date, BigInt, Map, Set, Uint8Array and Error.

SERIALIZATION JSON TRANSFORMERS ENVELOPES REGISTRY

OVERVIEW

Serialization means turning a value in memory into a flat piece of text (or bytes) that you can save to a file, put in a queue, or send over the network. Deserialization is the trip back: text in, value out.

JavaScript already ships with JSON.stringify and JSON.parse, and for plain objects, arrays, strings, numbers and booleans they are enough. The trouble starts with everything else.

JSON has no notion of a date, a set, or a very large integer. JSON.stringify(new Date()) gives you a string, and parsing it back gives you a string — the Date is gone. A Map comes back as {}. A BigInt throws outright. Round-tripping those types is not automatic, because the format itself cannot describe them.

This package fixes that by tagging such values on the way out and rebuilding them on the way in. It also hardens the way back in, because the text you deserialize usually came from somewhere you do not control.

WHEN YOU NEED IT

  • Your payload contains a Date, Map, Set, BigInt, Uint8Array or Error.
  • You put messages on a queue or send them between services and the consumer must know how to decode them.
  • You parse JSON that arrived from a client, a peer, or a cache, and want depth, size and prototype protection.

WHEN YOU DON'T

  • Plain data you control on both ends — JSON.stringify is fine.
  • Validating the shape of data. That is @zudojs/schema.
  • Encrypting or signing a payload. That is @zudojs/crypto.
In plain words: a serializer is an object with two methods, serialize and deserialize. Everything else on this page exists to make those two methods handle harder payloads safely.

INSTALLATION

Install the package. Its four Zudojs dependencies are pulled in for you.

$ npm install @zudojs/serialization

It depends on @zudojs/constants (tag names and limits), @zudojs/errors (the error classes it throws), @zudojs/types and @zudojs/validation.

These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.

QUICK START

createSerializer("json") gives you a serializer for plain data. This is the fast path: it hands straight over to JSON.stringify and JSON.parse.

import { createSerializer } from "@zudojs/serialization"; const serializer = createSerializer("json"); const json = serializer.serialize({ name: "Alice", age: 30 }); console.log(json); // {"name":"Alice","age":30} const back = serializer.deserialize<{ name: string; age: number }>(json); console.log(back.name, back.age); // Alice 30

What you should see: the exact JSON string above, then Alice 30. Nothing was tagged, because nothing needed it.

Watch out: "json" is the only format createSerializer accepts today. Any other name throws UnsupportedSerializationFormatError.

TYPE PRESERVATION

Turn on preserveTypes and the serializer walks your value instead of handing it to JSON.stringify. Whenever it meets a type JSON cannot express, it writes a small tagged object in its place.

A tagged object looks like { "$type": "Date", "$value": "2026-01-15T10:30:00.000Z" }. The $type key says which type it was; $value holds a JSON-safe form of it. On the way back, the matching transformer reads the tag and rebuilds the real value.

A plain object of yours that has its own string $type is written as { "$type": "Object", "$value": { … } } and read back unchanged, so user data can never be revived as another type. "Object" (ESCAPED_OBJECT_TAG) is reserved. A tag its transformer rejects reads back as plain data (throws under strict). BigInt tags are capped at 4096 decimal digits (SerializationLimits.MAX_BIGINT_DIGITS, shared with schema's coerce.bigint()).

Pass the option once to createSerializer and it applies to every call on that instance — both directions.

import { createSerializer } from "@zudojs/serialization"; const serializer = createSerializer("json", { preserveTypes: true }); const order = { id: "A-1", placedAt: new Date("2026-01-15T10:30:00Z"), total: 100n, tags: new Set(["urgent"]), attributes: new Map([["colour", "red"]]), }; const json = serializer.serialize(order); console.log(json); // {"id":"A-1", // "placedAt":{"$type":"Date","$value":"2026-01-15T10:30:00.000Z"}, // "total":{"$type":"BigInt","$value":"100"}, // "tags":{"$type":"Set","$value":["urgent"]}, // "attributes":{"$type":"Map","$value":[["colour","red"]]}} const back = serializer.deserialize<typeof order>(json); console.log(back.placedAt instanceof Date); // true console.log(back.total === 100n); // true console.log(back.tags.has("urgent")); // true console.log(back.attributes.get("colour")); // red

Values nested inside a Map or a Set are restored too, so a Map whose values are Dates comes back with real dates in it.

You can also decide per call, which overrides the instance default:

serializer.serialize(order, { pretty: true, indent: 4 }); serializer.deserialize(json, { strict: true, maxDepth: 64 });
Common mistake: serializing a BigInt without preserveTypes. The fast path is plain JSON.stringify, which throws "Do not know how to serialize a BigInt". A Date fails more quietly — it survives as a string and never becomes a Date again.

TRANSFORMERS

A transformer is the small object that knows how to convert one type. It has a type tag, a canSerialize check, and a serialize / deserialize pair.

Six come built in, and a JSONSerializer created with no arguments already has all of them registered:

Transformer Tag Tagged form
DateTransformer "Date" {"$type":"Date","$value":"2026-01-15T10:30:00.000Z"}
BigIntTransformer "BigInt" {"$type":"BigInt","$value":"100"}
MapTransformer "Map" {"$type":"Map","$value":[["k","v"]]}
SetTransformer "Set" {"$type":"Set","$value":["a","b"]}
BufferTransformer "Buffer" {"$type":"Buffer","$encoding":"base64","$value":"SGVsbG8="}
ErrorTransformer "Error" {"$type":"Error","name":"TypeError","message":"wrong type"}

To handle a type of your own, create a default JSONSerializer and call registerTransformer on it. The new transformer is added next to the six built-ins, so dates, maps, sets and the rest keep working.

import { JSONSerializer, type TypeTransformer } from "@zudojs/serialization"; class Money { constructor(public readonly cents: number) {} } const moneyTransformer: TypeTransformer<Money> = { type: "Money", canSerialize: (value): value is Money => value instanceof Money, // Return just the value; it is wrapped as { $type: "Money", $value } for you. serialize: (value) => String(value.cents), // deserialize always receives the full tagged object. deserialize: (value) => new Money(Number((value as { $value: string }).$value)), }; const serializer = new JSONSerializer({ defaults: { preserveTypes: true } }); serializer.registerTransformer(moneyTransformer); const json = serializer.serialize({ price: new Money(499), placedAt: new Date("2026-01-15T10:30:00Z"), }); console.log(json); // {"price":{"$type":"Money","$value":"499"}, // "placedAt":{"$type":"Date","$value":"2026-01-15T10:30:00.000Z"}} const back = serializer.deserialize<{ price: Money; placedAt: Date }>(json); console.log(back.price instanceof Money, back.price.cents, back.placedAt instanceof Date); // true 499 true

Since v1.2.0 serialize may return just the value, as above, and the serializer wraps it as { $type, $value }. Returning the full tagged object { $type: "Money", $value: … } yourself, as the built-ins do, still works: any plain object with its own string $type is taken as-is. deserialize always receives the full tagged object, so read $value from it. (Before v1.2.0 a bare return was written untagged and read back as a plain string.)

Your own registry

You can also pass a TransformerRegistry of your own, to new JSONSerializer({ transformers }) or to createSerializer("json", { transformers }). Since v1.2.0 the built-in transformers stay behind it: your registry is consulted first (so it can override a built-in tag such as "Date"), and dates, maps, sets and the rest still work. Pass builtins: false to use only your registry; createBuiltinTransformers() returns a registry holding all six if you want to start from them.

import { createSerializer, TransformerRegistry, type TypeTransformer } from "@zudojs/serialization"; class Money { constructor(public readonly cents: number) {} } const money: TypeTransformer<Money> = { type: "Money", canSerialize: (value): value is Money => value instanceof Money, serialize: (value) => value.cents, deserialize: (value) => new Money((value as { $value: number }).$value), }; const registry = new TransformerRegistry(); registry.register(money); const withBuiltins = createSerializer("json", { transformers: registry, preserveTypes: true }); console.log(withBuiltins.serialize({ price: new Money(499), tags: new Set(["sale"]) })); // {"price":{"$type":"Money","$value":499},"tags":{"$type":"Set","$value":["sale"]}} const onlyMine = createSerializer("json", { transformers: registry, builtins: false, preserveTypes: true }); try { onlyMine.serialize({ tags: new Set(["sale"]) }); } catch (error) { console.log((error as Error).name); // SerializeError console.log((error as Error).message); // Cannot serialize a Set with preserveTypes: no transformer is registered for it, and JSON // would write it as {} and lose its contents. Register a transformer for Set, or keep the // built-in transformers enabled (do not pass builtins: false). }
Nothing is silently flattened to {} any more: with preserveTypes, a value no transformer handles that JSON would write as {} (a Map, Set, WeakMap, WeakSet, WeakRef, Promise, RegExp, Error, ArrayBuffer or DataView) throws SerializeError naming the type. With the built-ins enabled that only happens for types that have no built-in transformer, such as RegExp. The message names the type with the right article (“an Error”, “an ArrayBuffer”), and suggests keeping the built-ins on only when they are off and one of them would handle the type; otherwise it tells you to register a transformer (since 1.2.1; 1.2.0 said “a Error” and suggested the built-ins even when they were on). Two things are unchanged: with builtins: false a Date is still written by JSON.stringify as an ISO string (and reads back as a string), and the fast path without preserveTypes is plain JSON.stringify, which still writes a Map as {}.

ENVELOPES

An envelope is the payload plus a small label describing it. The label carries the format, a schema version, a MIME content type and a character encoding, so whoever picks the message up knows how to decode it instead of guessing.

That matters for queues and cross-service calls: the producer and the consumer are different programs, possibly different versions of your code. A message stamped version: 1 can be rejected cleanly by a consumer that only understands version 1 — rather than misread.

serializeToEnvelope serializes and wraps in one step; deserializeFromEnvelope unwraps and parses.

import { JSONSerializer, serializeToEnvelope, deserializeFromEnvelope, } from "@zudojs/serialization"; const serializer = new JSONSerializer({ defaults: { preserveTypes: true }, }); const envelope = serializeToEnvelope( { orderId: "A-1", placedAt: new Date("2026-01-15T10:30:00Z") }, serializer, ); console.log(envelope.metadata); // { format: "json", version: 1, // contentType: "application/json", encoding: "utf-8" } console.log(envelope.data); // {"orderId":"A-1","placedAt":{"$type":"Date","$value":"2026-01-15T10:30:00.000Z"}} const back = deserializeFromEnvelope<{ orderId: string; placedAt: Date; }>(envelope, serializer, "json"); console.log(back.orderId, back.placedAt instanceof Date); // A-1 true

The third argument, "json", is the format you expect. If the envelope says something else, the call throws instead of feeding the wrong bytes to your parser.

You can also build and check envelopes by hand. createEnvelope wraps data you already serialized, unwrapEnvelope validates and returns the payload, assertValidEnvelope checks a value that arrived from the wire, and contentTypeForFormat maps a format name to its MIME type.

import { createEnvelope, unwrapEnvelope, contentTypeForFormat, } from "@zudojs/serialization"; const envelope = createEnvelope('{"order":123}'); console.log(unwrapEnvelope(envelope, "json")); // {"order":123} console.log(contentTypeForFormat("messagepack")); // application/msgpack
The envelope helpers need a serializer whose serialize returns a string. Both new JSONSerializer() and createSerializer("json") are typed that way, so either works here.

SERIALIZER REGISTRY

A registry is a lookup table from a name to a serializer. Code that needs to encode something asks the registry for "json" rather than importing a class, so you can swap the implementation in one place.

createDefaultRegistry() hands you one with the built-in JSON serializer already in it.

import { createDefaultRegistry, SerializerRegistry, JSONSerializer, } from "@zudojs/serialization"; const registry = createDefaultRegistry(); console.log(registry.names()); // ["json"] console.log(registry.size); // 1 const serializer = registry.get("json"); console.log(serializer.serialize({ ok: true })); // {"ok":true} // Or build your own and register instances under any name. const custom = new SerializerRegistry(); custom.register(new JSONSerializer({ defaults: { pretty: true } })); console.log(custom.has("json")); // true

A serializer registers itself under its own name property, so JSONSerializer always lands on "json", and registering a second one under that name replaces the first.

Common mistake: asking for a name nobody registered. registry.get("msgpack") throws SerializerNotFoundError, which lives in @zudojs/errors — not in this package. Use registry.has(name) first if a miss is expected.
import { createDefaultRegistry } from "@zudojs/serialization"; import { SerializerNotFoundError } from "@zudojs/errors"; const registry = createDefaultRegistry(); try { registry.get("msgpack"); } catch (error) { if (error instanceof SerializerNotFoundError) { console.log(error.message); // No serializer registered with name: "msgpack" } }

UNTRUSTED INPUT

deserialize is the risky direction. The string you pass it usually came from a request body, a queue message or a cache — places another program can write to.

The most famous trap is prototype pollution. In JavaScript every object inherits from a shared parent object. A payload containing the key __proto__ can, if it is copied naively, write onto that shared parent — and suddenly every object in your program appears to have an isAdmin field.

On the preserveTypes path, which rebuilds objects key by key, __proto__, constructor and prototype are dropped. Set allowUnsafeKeys: true only for payloads you trust; even then they are added as ordinary own properties, never as a prototype.

import { JSONSerializer } from "@zudojs/serialization"; const serializer = new JSONSerializer(); const fromTheWire = '{"user":{"__proto__":{"isAdmin":true},"name":"Alice"}}'; const value = serializer.deserialize<{ user: { name: string } }>( fromTheWire, { preserveTypes: true, maxSize: 64 * 1024, maxDepth: 32 }, ); console.log(value.user.name); // Alice console.log(Object.keys(value.user)); // ["name"] console.log(({} as Record<string, unknown>).isAdmin); // undefined

The other guards on that call:

  • maxSize bounds the input before parsing. Default: 10 MB.
  • maxDepth bounds nesting, so a deeply nested payload cannot exhaust the stack. Default: 128 on the preserveTypes path; on the fast path it is enforced whenever you set it (per call or as an instance default).
  • An unrecognised $type tag is treated as ordinary data, so a peer cannot stop your consumer by sending {"$type":"anything"} — and your own records may legitimately carry a $type field. Pass strict: true to make an unknown tag an error instead.
  • strict also rejects input that is not valid JSON with a clear message.

Errors get the same care. A stack trace names absolute file paths and internal call structure, and a serialized error routinely ends up in a log sink, so stacks are left out unless you ask:

const json = serializer.serialize( { failure: new Error("boom") }, { preserveTypes: true }, ); console.log(json); // {"failure":{"$type":"Error","name":"Error","message":"boom"}} // Opt in when you really need it: serializer.serialize( { failure: new Error("boom") }, { preserveTypes: true, includeStack: true }, );

On the way back in, a built-in error class is rebuilt with its own constructor: TypeError, RangeError, SyntaxError, ReferenceError, EvalError, URIError and AggregateError, so instanceof holds. Any other name, such as your own MyError, comes back as an Error with name set. The rebuilt error's stack is only its header line:

const wire = serializer.serialize({ failure: new TypeError("bad input") }, { preserveTypes: true }); const { failure } = serializer.deserialize<{ failure: Error }>(wire, { preserveTypes: true }); console.log(failure instanceof TypeError); // true console.log(failure.stack); // "TypeError: bad input" — no frames
A stack that arrives from the wire (sent with includeStack: true) is attached to the rebuilt error as a hidden originalStack property rather than replacing the real stack, so a reconstructed error never lies about where it came from. Changed in 1.2.1: a rebuilt error used to be a plain Error (so instanceof TypeError was false), and its stack carried the deserializer's own frames, which looked like the original stack. Now the class is right and stack is the header line only.
Watch out: the fast path (no preserveTypes) is plain JSON.parse. There, __proto__ survives as an inert own property and still never reaches the prototype — but do not spread or deep-merge such an object into another without screening its keys.

ENCODING UTILITIES

Four small helpers convert between text and bytes. UTF-8 is the standard way to write text as bytes; Base64 writes arbitrary bytes as safe ASCII characters, which is how BufferTransformer fits binary data into JSON.

They pick the right implementation for Node.js or the browser automatically.

import { toBase64, fromBase64, encodeUtf8, decodeUtf8, } from "@zudojs/serialization"; const bytes = encodeUtf8("Hello, Zudo!"); console.log(bytes.length); // 12 console.log(decodeUtf8(bytes)); // Hello, Zudo! const b64 = toBase64(bytes); console.log(b64); // SGVsbG8sIFp1ZG8h console.log(decodeUtf8(fromBase64(b64))); // Hello, Zudo!

FULL EXAMPLE

One producer wraps an order in an envelope; one consumer looks the serializer up by name and unwraps it. This is the whole package in twenty-five lines.

import { JSONSerializer, SerializerRegistry, serializeToEnvelope, deserializeFromEnvelope, } from "@zudojs/serialization"; // Both sides share the same configuration. const serializer = new JSONSerializer({ defaults: { preserveTypes: true, maxSize: 1_000_000 }, }); const registry = new SerializerRegistry(); registry.register(serializer); // Producer: wrap the order for transport. const order = { id: "A-1", placedAt: new Date("2026-09-05T12:00:00Z"), total: 999999999999999n, items: new Set(["widget", "gadget"]), }; const envelope = serializeToEnvelope(order, serializer); console.log(envelope.metadata.format, envelope.metadata.version); // json 1 // Consumer: look the serializer up by name, then unwrap. const decoder = registry.get("json") as JSONSerializer; const back = deserializeFromEnvelope<typeof order>( envelope, decoder, "json", ); console.log(back.id); // A-1 console.log(back.placedAt instanceof Date); // true console.log(back.total === 999999999999999n); // true console.log(back.items.has("widget")); // true

API REFERENCE

Everything below is exported from @zudojs/serialization.

Functions

Name What it does Notes
createSerializer(format, options?) Creates a serializer for a format. Only "json" is supported. Options: transformers (consulted before the built-ins), builtins (default true), plus every serialize/deserialize option (pretty, preserveTypes, maxSize, maxDepth, strict, …) — kept as per-instance defaults.
createBuiltinTransformers() Returns a new TransformerRegistry holding the six built-in transformers. New in v1.2.0. A starting point for a registry you pass with builtins: false.
createDefaultRegistry() Returns a registry holding the built-in JSON serializer. Size 1, name "json".
createEnvelope(data, format?, options?) Wraps already-serialized data with metadata. Format defaults to "json"; version, contentType and encoding are filled in for you.
unwrapEnvelope(envelope, expectedFormat?) Validates an envelope and returns its payload. Throws on a malformed envelope, a newer schema version, or a format mismatch.
assertValidEnvelope(envelope) Throws unless the value is a well-formed envelope. A TypeScript assertion — narrows unknown to SerializedEnvelope.
contentTypeForFormat(format) Maps a format name to its MIME type. Unknown formats give application/octet-stream.
serializeToEnvelope(value, serializer, format?) Serializes a value and wraps it in one step. Takes the contentType from the serializer.
deserializeFromEnvelope(envelope, deserializer, expectedFormat?) Unwraps an envelope and parses the payload. Binary payloads must be UTF-8; any other encoding is rejected.
toBase64(data) / fromBase64(text) Convert bytes to and from Base64. Works in Node.js and the browser.
encodeUtf8(text) / decodeUtf8(data) Convert text to and from UTF-8 bytes. Works in Node.js and the browser.

Classes

Name What it does Notes
JSONSerializer The JSON serializer. serialize, deserialize, registerTransformer, plus name and contentType. Constructor takes JSONSerializerOptions: { transformers?, builtins?, defaults? }. Your transformers are consulted first and the built-ins stay behind them unless builtins: false. With no arguments it registers all six built-in transformers.
SerializerRegistry Name → serializer lookup. register, unregister, get, has, names, clear, size. Keyed by the serializer's own name. get throws when the name is unknown.
TransformerRegistry Tag → transformer lookup. register, unregister, get, findForValue, has, types, clear, size. Holds at most 256 transformers; register throws beyond that.

Built-in transformers

DateTransformer, BigIntTransformer, MapTransformer, SetTransformer, BufferTransformer and ErrorTransformer are exported as values, so you can register a subset on your own TransformerRegistry. See the table in Transformers.

Types

Name What it does Notes
Serializer<TValue, TSerialized> The contract every serializer satisfies. name, contentType, serialize, deserialize.
SerializeOptions Options for serialize. pretty, preserveTypes, maxDepth, maxSize, indent, includeStack, allowUnsafeKeys.
DeserializeOptions Options for deserialize. preserveTypes, maxDepth, maxSize, strict, allowUnsafeKeys.
JSONSerializerOptions Constructor options for JSONSerializer. transformers, builtins, defaults. Exported since v1.2.0.
TypeTransformer<TValue> The contract a custom transformer implements. type, canSerialize, serialize(value, options?) (return the bare value or the full tagged object), deserialize(value, options?) (always receives the full tagged object).
SerializedEnvelope An envelope: { metadata, data }. Paired with SerializationMetadata (format, version, contentType, encoding).
SerializedValue string | Uint8Array. What a serializer may produce.
SerializationFormat Format identifier. "json" | "text" | "binary" | "messagepack" | string.

Errors

These are thrown by this package but defined and exported by @zudojs/errors. Import them from there, not from @zudojs/serialization.

Name When it is thrown Notes
SerializerNotFoundError SerializerRegistry.get(name) finds nothing. Carries serializerName.
TransformerNotFoundError TransformerRegistry.get(type) finds nothing. Carries transformerType.
UnsupportedSerializationFormatError createSerializer gets a format other than "json". Carries the offending format.
SerializationPayloadTooLargeError Input or output exceeds maxSize. A SerializationError.
SerializationDepthError Nesting exceeds maxDepth. A SerializationError.
InvalidSerializedDataError Input that is not valid JSON, or an unknown or malformed tag under strict. A SerializationError.
TransformerError A transformer rejects a tag under strict, or a transformer claims the reserved "Object" tag. A SerializationError.
SerializeError A value cannot be written, such as an invalid Date, an oversized BigInt, or (with preserveTypes) a Map, Set, RegExp, Promise or similar that no transformer handles and JSON would write as {} A SerializationError.

Circular references are reported by the @zudojs/validation guard (CircularReferenceError). Envelope problems (a malformed envelope, a format mismatch, a newer schema version) throw InvalidSerializedDataError; registering a 257th transformer throws TransformerError.

COMMON MISTAKES

  • Serializing with preserveTypes but deserializing without it. You get back the raw tagged objects — { $type: "Date", $value: "2026-01-15T10:30:00.000Z" } instead of a Date. Fix: set the option on both calls, or set it once via createSerializer("json", { preserveTypes: true }).
  • Sending a BigInt down the fast path. JSON.stringify throws "Do not know how to serialize a BigInt". Fix: turn on preserveTypes.
  • Passing builtins: false and then serializing a Map. Under preserveTypes it throws SerializeError, and a Date quietly becomes a string. Fix: leave the built-ins on (the default) and let your registry override only the tags you need.
  • Importing SerializerNotFoundError from @zudojs/serialization. The import fails — the class is not re-exported here. Fix: import { SerializerNotFoundError } from "@zudojs/errors";
  • Expecting a stack trace on a deserialized error. Stacks are left out by default, a rebuilt error's own stack is only its header line, and a wire-supplied one lands on originalStack. Fix: pass includeStack: true when serializing, and read originalStack on the other side.

COMPLETE EXPORT INDEX

Every name @zudojs/serialization exports from its package root at v1.1.1 — 31 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 31 exports
Classes (3)
JSONSerializer SerializerRegistry TransformerRegistry
Functions (12)
assertValidEnvelope contentTypeForFormat createDefaultRegistry createEnvelope createSerializer decodeUtf8 deserializeFromEnvelope encodeUtf8 fromBase64 serializeToEnvelope toBase64 unwrapEnvelope
Interfaces (7)
CreateSerializerOptions DeserializeOptions SerializationMetadata SerializedEnvelope SerializeOptions Serializer TypeTransformer
Type aliases (2)
SerializationFormat SerializedValue
Constants (7)
BigIntTransformer BufferTransformer DateTransformer ErrorTransformer ESCAPED_OBJECT_TAG MapTransformer SetTransformer