Canonical bytes
For any value a validator-backed schema accepts, there is exactly one valid encoding. Field order, enum order, integer spelling, and byte order are all derived from the schema. None of them is configurable.
Field order is derived
Section titled “Field order is derived”shorn writes fields in the order of their names, compared as UTF-16 code units, ascending. That is JavaScript’s default string comparison.
const Person = z.object({ name: z.string(), // rank 1 age: z.int().nonnegative(), // rank 0 sex: z.enum(["M", "F", "X"]), // rank 2});age is written first even though it was declared second. Where the bytes go shows this byte by byte. Because the order comes from the names alone, every validator and both the high-level and low-level APIs agree on it.
The encoder applies the sort. The m API cannot override it, because field order is a rule of the wire format, not a schema option.
Enum members are sorted too
Section titled “Enum members are sorted too”The same rule orders enum members, so the index of a member never depends on where you declared it:
z.enum(["M", "F", "X"]); // sorted: ["F", "M", "X"] → 0, 1, 2Declaring ["X", "F", "M"] gives identical bytes. Adding a member shifts the index of every member that sorts after it, which is why versioned payloads need a wire fingerprint.
The same bytes from every validator
Section titled “The same bytes from every validator”Three validators, one shape, identical output:
z.object({ name: z.string(), age: z.int().nonnegative() });v.object({ name: v.string(), age: v.pipe(v.number(), v.integer(), v.minValue(0)) });type({ name: "string", age: "number.integer >= 0" });// all three: the same bytes, the same fingerprintThis works because the wire shape comes from JSON Schema, which all three validators produce. The signature leaves out rejectUnknown, the one thing validators handle differently, because it does not change the encoded bytes.
Recursive types agree too, even though validators spell them differently: one points a $ref at its definition, another inlines a copy and refers back from inside it. shorn folds a copy of a definition back onto the definition, so both spellings produce one signature.
Integers have one spelling
Section titled “Integers have one spelling”The decoder rejects overlong varints. 1 must be 0x01, never 0x81 0x00. This keeps every encoding unique, which is what content addressing, deduplication, and byte-level equality all depend on.
What canonical does not cover
Section titled “What canonical does not cover”- Floats:
-0and0are different byte strings. Validator-backed schemas refuseNaN. The low-levelm.float32()andm.float64()accept it, and several NaN bit patterns decode without error. Do not use low-level NaN values for content addressing. - String normalization:
"é"as one code point and aseplus a combining accent are two different strings, and both encode faithfully. Normalize first if you need them to be equal. - Decoded key order: a decoded object has shorn’s key order, not the order of your original object. It is the same value, so
toEqualandisDeepStrictEqualpass. ButJSON.stringify(decoded) === JSON.stringify(original)fails, becauseJSON.stringifyis sensitive to key order. Compare values, not serialized strings. (We measured restoring declaration order and rejected it: it cost 6 to 15% of decode speed.)
A round trip returns the same value
Section titled “A round trip returns the same value”decode(encode(x)) gives back x itself, not merely something equivalent. A format: "date-time" string is stored as epoch milliseconds, which shrinks about 25 bytes to 6. But epoch milliseconds cannot remember how many fractional digits the string had or how its offset was spelled.
So shorn accepts only the one spelling that survives the trip, the toISOString() one, and refuses every other spelling rather than silently normalizing it. Uppercase UUIDs follow the same rule: refuse anything that would come back different. See Date, BigInt, Map, Set.