Skip to content

Canonical Bytes

A value and wire shape have exactly one valid encoding. This is a stronger guarantee than successful round-tripping, and it determines several format choices.

Field order is the rank of the field’s name in UTF-16 code-unit ascending order — 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
});
encode(Person, { name: "Grace", age: 45, sex: "F" });
// [45, 5, 71, 114, 97, 99, 101, 0]
// ^age ^len "Grace" ^sex

age is written first even though it was declared second. The order is derived from field names, so validators and the high- and low-level APIs all produce the same result.

The encoder applies the sort. The m API cannot override it because canonical field order is a wire-format rule, not a schema option.

z.enum(["M", "F", "X"]); // sorted: ["F", "M", "X"] → 0, 1, 2

Declaring ["X", "F", "M"] produces identical bytes. It also means adding a member shifts every index at or after it — one of the three edits that corrupts silently. See Fingerprinting.

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 fingerprint

This works because the wire shape comes from JSON Schema. The signature excludes rejectUnknown, which validators handle differently but which does not change the encoded bytes.

Overlong varints are rejected: 1 must be 0x01, never 0x81 0x00. This keeps the encoding unique, which is required for content addressing, deduplication, and byte-level equality.

  • Floats. float64, little-endian, always 8 bytes. -0 and 0 are distinct byte strings; NaN is refused by every vendor before shorn sees it.
  • String normalization. "é" as one code point and as e plus a combining accent are different strings, both encoded faithfully. Normalize first if you need them equal.
  • Decoded key order. The bytes are canonical; the decoded object’s key order is shorn’s, not your original object’s.

decode(encode(x)) returns x, not merely an equivalent value. Converting a format: "date-time" string to an epoch integer could reduce it from about 25 bytes to 5, but it would not preserve the original ISO-8601 spelling. For example, Z could come back as +00:00.

That conversion preserves the instant but changes its representation. shorn therefore leaves it to the application. See ADR 0003.