This is the full developer documentation for shorn # Errors > EncodeError, DecodeError, and what every message you can hit means. ```ts class EncodeError extends Error {} class DecodeError extends Error { readonly offset: number } ``` | Error | Thrown when | | ------------- | ---------------------------------------------------------------- | | `EncodeError` | validation failed on the way in, or the schema cannot be encoded | | `DecodeError` | the bytes are malformed, **or** validation failed on the way out | `DecodeError.offset` is the byte position reached. For a validation failure, it equals the payload length because structural decoding must consume every byte before validation runs. ```ts try { decode(Person, bytes); } catch (error) { if (error instanceof DecodeError) { console.error(`bad payload at byte ${error.offset}: ${error.message}`); } } ``` To avoid exceptions, use `safeDecode`. It returns either `{ success: true, data }` or `{ success: false, error }` and wraps values that are not already `Error` objects. ## Schema-construction errors [Section titled “Schema-construction errors”](#schema-construction-errors) These are all `EncodeError` instances thrown when the codec is built. See [Rejected Shapes](/schemas/rejected-shapes/) for workarounds. | Message | Cause | | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `Records and open objects are not currently supported` | `z.looseObject`, `z.record`, `v.record` | | `Only nullable JSON Schema unions are currently supported` | a general or discriminated union | | `Only nullable JSON Schema type arrays are currently supported` | a `type` array with >1 non-null entry | | `Arrays require an item schema` | an array with no `items` | | `Empty enums are unsupported` | an enum with no members | | `Unsupported JSON Schema literal` | a literal that is not string, number, boolean, or null | | `Unsupported Standard JSON Schema type X` | a type with no wire shape | | `Unsupported Standard JSON Schema node` | a non-object node where a schema was expected | | `Required property "x" has no schema` | `required` names a property absent from `properties` | | `Schemas with different input and output wire shapes require a bidirectional codec and are not yet supported` | a default or widening refinement makes the sides differ | | `Standard Schema provides validation but not structure; pass a Standard JSON Schema implementation as the second argument` | Valibot, Zod < 4.2, ArkType < 2.1.28 | | `This schema already decodes to null; wrapping it in nullable() would give null two encodings` | `m.literal(null).nullable()`, or a second null marker over one already reachable | | `This schema already decodes to undefined; wrapping it in optional() would give undefined two encodings` | a second presence marker over one already reachable | | `fingerprinted() needs a codec built from a Standard JSON Schema; compile() returns one, the low-level m API does not` | `fingerprinted(m.object(...))` | | `Fingerprint bytes must be 1, 2, 3 or 4, received X` | out-of-range `bytes` option | ### Rich types [Section titled “Rich types”](#rich-types) ```plaintext — shorn encodes the wire shape; convert rich types at the edge (README: Dates, BigInt, Map and Set) ``` shorn preserves the validator’s original reason and appends guidance. This applies to `Date`, `bigint`, `Map`, `Set`, `undefined`, `NaN`, and transforms. See [Date, BigInt, Map, Set](/schemas/rich-types/). ### Async [Section titled “Async”](#async) ```plaintext This Standard Schema validates asynchronously; use encodeAsync/decodeAsync with the Standard Schema. Neither accepts a compiled or fingerprinted codec. ``` The second sentence clarifies an important limitation: async entry points accept schemas, not compiled or fingerprinted codecs. ## Encode-time value errors [Section titled “Encode-time value errors”](#encode-time-value-errors) | Message | Cause | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `Unknown object property "x"` | an extra property where the vendor left `additionalProperties` absent — ArkType by default, Valibot’s `object` and `looseObject` | | *validation issues, joined by `;`* | your refinements failed; paths prefixed as `field.nested: message` | ## Decode-time errors [Section titled “Decode-time errors”](#decode-time-errors) All `DecodeError` with an `offset`. | Message | Cause | | ------------------------------------------------------------------------- | -------------------------------------- | | `Expected a Uint8Array, received X` | wrong input type; offset 0 | | `Unexpected trailing data` | bytes remained after a complete value | | `Payload was written by a different schema (expected fingerprint XXXXXX)` | the schema changed | | *out-of-bounds read* | truncated payload | | *non-canonical varint* | overlong, e.g. `[129, 0]` for `1` | | *unsafe integer* | a varint beyond the safe integer range | | *invalid UTF-8* | decoding is fatal, not replacing | | *invalid boolean* | a byte other than `0` or `1` | | *invalid enum index* | past the last member | | *element count exceeds remaining input* | a count the payload cannot satisfy | Handle fingerprint mismatches explicitly in production. In the mismatch test, decoding without a fingerprint produced a **wrong value without an error** about 27% of the time. See [Schema Evolution](/versioning/schema-evolution/). ## One error that is not a `DecodeError` [Section titled “One error that is not a DecodeError”](#one-error-that-is-not-a-decodeerror) A schema nested about 5,900 levels deep can overflow the JavaScript stack and throw `RangeError` instead of `DecodeError`. This requires a hostile **schema**, not only hostile bytes. Limit schema depth if schemas come from untrusted input. See [Hostile Input](/hostile-input/). # Functions > Signatures and behavior for encode, decode, the safe and async variants, compile, and fingerprinted. Each function has two overloads. One accepts schemas that implement both Standard interfaces. The other accepts a Standard Schema plus `{ structure }`. ## `encode` [Section titled “encode”](#encode) ```ts encode(schema: S, value: InferOutput): Uint8Array; encode(schema: S, value: InferOutput, options: CompileOptions): Uint8Array; ``` Validates, then writes bytes. Throws `EncodeError` if validation fails or the schema cannot be encoded. The returned `Uint8Array` is an **exact-size copy**, not a view into a reused buffer. It is safe to retain. The wire plan is cached by schema identity. ## `decode` [Section titled “decode”](#decode) ```ts decode(schema: S, bytes: Uint8Array): InferOutput; decode(schema: S, bytes: Uint8Array, options: CompileOptions): InferOutput; ``` Reads the structure, then validates. Throws `DecodeError` for malformed bytes **and** for validation failures on the way out. Trailing bytes cause an error. An input that is not a `Uint8Array` produces a `DecodeError` rather than a raw `TypeError`. Cross-realm arrays from `node:vm`, an iframe, or jsdom are accepted through a tag check when `instanceof` fails. ## `safeEncode` / `safeDecode` [Section titled “safeEncode / safeDecode”](#safeencode--safedecode) ```ts safeEncode(schema, value, options?): SafeResult; safeDecode(schema, bytes, options?): SafeResult>; type SafeResult = { success: true; data: T } | { success: false; error: Error }; ``` Same behavior without throwing. Non-`Error` throws are wrapped, so `result.error` is always an `Error`. ## `encodeAsync` / `decodeAsync` [Section titled “encodeAsync / decodeAsync”](#encodeasync--decodeasync) ```ts encodeAsync(schema, value, options?): Promise; decodeAsync(schema, bytes, options?): Promise>; ``` Use these functions for schemas with **asynchronous** refinements. Both accept a schema, not a codec, so async validation does not compose with `fingerprinted()`. Carry `codec.fingerprintHex` separately if you need both. There are no safe async variants. See [Validation](/core-concepts/validation/). Calling `encode`/`decode` on an async schema throws: > This Standard Schema validates asynchronously; use encodeAsync/decodeAsync with the Standard Schema. Neither accepts a compiled or fingerprinted codec. ## `compile` [Section titled “compile”](#compile) ```ts compile(schema: S): Schema>; compile(schema: S, options: CompileOptions): Schema>; ``` Returns the cached wire plan as a codec with `.encode()` and `.decode()`. Aliases: `codec`, `fromStandard`. **No build step.** `compile` builds a tree of `Schema` objects in memory and writes nothing to disk. Repeated calls with the same schema and structure object return the **same** cached instance. An object schema with no optional fields also builds a decoder with `new Function`, falling back to the interpreted path where a Content Security Policy forbids it — see [Compilation and Caching](/core-concepts/compile-and-caching/). ## `fingerprinted` [Section titled “fingerprinted”](#fingerprinted) ```ts fingerprinted(codec: Schema, options?: FingerprintOptions): FingerprintedSchema; ``` Prefixes payloads with a short FNV-1a digest of the schema’s canonical wire signature. ```ts const codec = fingerprinted(compile(Person)); codec.encode(person); // 3 + payload bytes codec.fingerprint; // Uint8Array — a fresh copy every read codec.fingerprintHex; // "7236d1" — the Map key for dispatch ``` Throws `EncodeError` for a codec without a signature: > fingerprinted() needs a codec built from a Standard JSON Schema; compile() returns one, the low-level m API does not It also throws if `bytes` is outside 1–4. Performance is effectively the same at every width, so use the default 3 bytes unless you have a specific protocol constraint. See [Fingerprinting](/versioning/fingerprinting/). ### `FingerprintedSchema` [Section titled “FingerprintedSchema”](#fingerprintedschema) | Member | Type | Notes | | ---------------- | ------------ | ------------------------------------------ | | `fingerprint` | `Uint8Array` | Fresh copy every read; cannot key a `Map` | | `fingerprintHex` | `string` | Lowercase hex, immutable, the dispatch key | `fingerprint` returns a copy so callers cannot mutate the codec’s internal bytes. Otherwise, an accidental write could make the codec non-canonical while it still round-trips against itself. ## `Schema` [Section titled “Schema\”](#schemat) ```ts abstract class Schema { encode(value: T): Uint8Array; decode(value: Uint8Array): T; optional(): OptionalSchema; nullable(): NullableSchema; readonly signature?: string; // only on codecs built from a JSON Schema } ``` `signature` is type-only on the base class, so users who do not import `fingerprinted()` pay no runtime cost for it. `_encode`, `_decode`, and `_minWidth` are internal and may change in a minor release. See [Low-Level m API](/wire-format/low-level-api/). # m Builders > Reference for the low-level wire builders, Reader and Writer, and Infer. `m` builds a codec directly from the wire format without a validation library. It is an escape hatch, not a replacement for your validator. See [Low-Level m API](/wire-format/low-level-api/). ```ts import { m, type Infer } from "shorn"; const Point = m.object({ x: m.int(), y: m.int() }); type Point = Infer; // { x: number; y: number } ``` ## Primitives [Section titled “Primitives”](#primitives) | Builder | Type | Wire | | ------------- | -------------------- | ---------------------------------- | | `m.string()` | `Schema` | varint byte length + UTF-8 | | `m.bytes()` | `Schema` | varint byte length + raw | | `m.boolean()` | `Schema` | one byte, `0` or `1` | | `m.uint()` | `Schema` | varint; non-negative safe integers | | `m.int()` | `Schema` | ZigZag varint | | `m.float32()` | `Schema` | 4 bytes, little-endian | | `m.float64()` | `Schema` | 8 bytes, little-endian | `m.bytes()` and `m.float32()` have no JSON Schema form, so no validator selects them for you. They are also the only two builders a `compile()` codec can never reach, which is why the Standard Schema compiler does not go through `m` at all. `m` does not tree-shake per builder `m` is a single object, so `import { m }` retains all twelve builders whether you call two or twelve — about 3.9 KB gzip. Bundlers cannot drop a property of a live object. Splitting the builders into named exports would cut that to roughly 3.0 KB for a typical three-builder import; it was measured and declined, because `m` is the escape hatch and `compile()` users see none of the saving. See [ADR 0004](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0004-m-stays-one-object.md). ## `m.literal(value)` [Section titled “m.literal(value)”](#mliteralvalue) ```ts m.literal(value: T): Schema; ``` Literals use zero bytes. They cannot be array elements because the decoder could not verify the declared element count against the payload length. ## `m.enum(values)` [Section titled “m.enum(values)”](#menumvalues) ```ts m.enum(values: T): Schema; ``` A varint index into the **sorted, deduplicated** member list, so declaration order does not affect bytes. At least one member required; an index past the last is a `DecodeError`. ## `m.array(item)` [Section titled “m.array(item)”](#marrayitem) ```ts m.array(item: Schema): Schema; ``` Writes a varint count followed by the elements in order. Codec construction fails if `item` can use zero bytes. Arrays are limited to 1,000,000 elements, and impossible counts are rejected before allocation. ## `m.tuple(items)` [Section titled “m.tuple(items)”](#mtupleitems) ```ts m.tuple[]>(items: S): Schema>; ``` Elements only; length from the schema, positions never reordered. **May** contain zero-width elements, unlike `m.array`. ## `m.object(shape)` [Section titled “m.object(shape)”](#mobjectshape) ```ts m.object(shape: S): Schema>; ``` Presence bitmap for optional fields (`ceil(n / 8)` bytes, omitted when nothing is optional), then values in **canonical key order** — UTF-16 ascending. Declaration order is irrelevant. Unknown properties throw on encode. ## `.optional()` and `.nullable()` [Section titled “.optional() and .nullable()”](#optional-and-nullable) ```ts m.object({ name: m.string(), nickname: m.string().optional(), // a bit in the presence bitmap manager: m.string().nullable(), // discriminator byte, then the value }); ``` `optional()` is only meaningful as an object field — it *is* the bitmap bit. `nullable()` works anywhere and always costs one byte. ### Markers never stack [Section titled “Markers never stack”](#markers-never-stack) A second marker for a value the schema can already produce would give that value two encodings, so `[0]` and `[1, 0]` would both decode to `undefined` and decoding would stop being injective. shorn refuses that, in one of two ways. **Repeating the same wrapper is a no-op.** It returns the identical object, so this is safe in generic code that does not know what it was handed: ```ts const a = m.string().optional(); a.optional() === a; // true ``` **Producing a second marker for the same value throws an `EncodeError`** when the codec is built: ```ts m.literal(null).nullable(); // throws: already decodes to null m.string().optional().nullable().optional(); // throws: already decodes to undefined m.string().nullable().optional().nullable(); // throws: already decodes to null compile(z.string().nullable()).nullable(); // throws, carried through compile ``` Both flags propagate through the other wrapper, `compile()`, and `fingerprinted()`, so a stack three deep is caught as readily as one. Mixing the two markers once is fine and is a real shape: `m.string().optional().nullable()` distinguishes absent from null. ## `Reader` and `Writer` [Section titled “Reader and Writer”](#reader-and-writer) ```ts class Writer { byte(value: number): void; bytes(value: Uint8Array): void; varuint(value: number): void; varuintBigInt(value: bigint): void; string(value: string): void; finish(): Uint8Array; reset(): void; } class Reader { readonly position: number; readonly done: boolean; byte(): number; bytes(length: number): Uint8Array; string(): string; varuint(): number; varuintBigInt(): bigint; varuintWide(): number | bigint; } ``` ```ts class Pair extends Schema<[number, number]> { _minWidth = 2; // fewest bytes a value can occupy _encode(writer: Writer, value: [number, number]) { writer.varuint(value[0]); writer.varuint(value[1]); } _decode(reader: Reader): [number, number] { return [reader.varuint(), reader.varuint()]; } } ``` Set `_minWidth` if the schema may be used as an array element. It lets `m.array` reject an impossible count before allocating. A schema with width 0 cannot be an array element. `reader.bytes(n)` returns a fresh subarray and costs about 24 ns in the benchmark. Prefer a `byte()` loop on performance-critical paths. This surface is unstable: `_encode`, `_decode`, and `_minWidth` can change in a minor release. ## Types [Section titled “Types”](#types) ```ts type Infer> = S["_output"]; type Shape = Record>; type ObjectOutput; // the decoded object type for a shape ``` ## Byte-compatible with `compile` [Section titled “Byte-compatible with compile”](#byte-compatible-with-compile) ```ts compile(z.object({ name: z.string(), age: z.int().nonnegative() })); m.object({ name: m.string(), age: m.uint() }); // byte-identical output for the same value ``` `m` cannot override canonical field order or the enum index base, which keeps it byte-compatible with `compile`. `test/golden.test.ts` verifies this behavior. # API Overview > The whole public surface on one page. ```ts // Encode and decode encode(schema, value, options?): Uint8Array; decode(schema, bytes, options?): Output; safeEncode(schema, value, options?): SafeResult; safeDecode(schema, bytes, options?): SafeResult; encodeAsync(schema, value, options?): Promise; decodeAsync(schema, bytes, options?): Promise; // Codecs compile(schema, options?): Schema; fingerprinted(codec, options?): FingerprintedSchema; // Low-level m.string() | m.bytes() | m.boolean() | m.uint() | m.int() | m.float32() | m.float64() | m.literal(v) | m.enum([...]) | m.array(item) | m.tuple([...]) | m.object({...}); // Errors EncodeError; DecodeError; // .offset ``` `codec` and `fromStandard` are aliases of `compile`. ## Choosing an entry point [Section titled “Choosing an entry point”](#choosing-an-entry-point) | Situation | Use | | ------------------------------------------- | ------------------------------------ | | Ordinary code, throwing is fine | `encode` / `decode` | | Untrusted input | `safeEncode` / `safeDecode` | | Async refinement | `encodeAsync` / `decodeAsync` | | A codec object to pass around | `compile` | | **Stored, queued, version-crossing** | **`fingerprinted(compile(schema))`** | | No validator, or you need `bytes`/`float32` | `m` | All entry points use the same structural decode path through `Schema.decode`, so they report the same errors. ## Options [Section titled “Options”](#options) ```ts interface CompileOptions { readonly structure: StandardJSONSchemaV1; } interface FingerprintOptions { readonly bytes?: 1 | 2 | 3 | 4; // default 3 } ``` `{ structure }` is required for validators implementing Standard Schema but not Standard JSON Schema — Valibot always, Zod before 4.2, ArkType before 2.1.28. ## Types [Section titled “Types”](#types) ```ts type SafeResult = | { success: true; data: T } | { success: false; error: Error }; type EncodableStandardSchema = StandardSchemaV1 & StandardJSONSchemaV1; type Infer> = S["_output"]; ``` `Infer` reads the output type off a low-level `m` codec. For schema-backed codecs use your validator’s inference (`z.infer`, `v.InferOutput`, `typeof T.infer`). Also exported: `Schema`, `OptionalSchema`, `NullableSchema`, `FingerprintedSchema`, `Reader`, `Writer`, `ObjectOutput`, `Shape`, `CompileOptions`, `FingerprintOptions`. ## The three-line version [Section titled “The three-line version”](#the-three-line-version) ```ts const Person = z.object({ name: z.string(), age: z.int().nonnegative() }); export const wire = compile(Person); // pinned RPC export const stored = fingerprinted(compile(Person)); // stored or queued ``` ## Reference [Section titled “Reference”](#reference) [Functions](/api/functions/) · [m Builders](/api/m/) · [Errors](/api/errors/) # shorn vs JSON > A quarter of the bytes and faster both ways — but only against JSON that actually produces bytes. JSON is the baseline most TypeScript projects are replacing. ## Size [Section titled “Size”](#size) | Fixture | shorn | JSON | Saving | | -------------- | ------------: | ---------: | -----: | | Person | **8** | 35 | 77% | | Unicode person | **31** | 58 | 47% | | Nested event | **43** | 163 | 74% | | 100 events | **4,135** | 16,148 | 74% | | 100,000 events | **4,231,777** | 16,340,686 | 74% | The Unicode row shows where the savings come from: shorn removes field names and syntax, not string content. Payloads dominated by structure and numbers can be about 75% smaller than JSON. Payloads dominated by free text see smaller gains. Compressed, on 100,000 repetitive events: | | shorn | JSON | Saving | | --------- | ----------: | --------: | -----: | | Gzip | **924,494** | 1,474,952 | 38% | | Brotli q6 | **603,189** | 985,919 | 39% | Repeated keys are what a compressor is good at, so compression narrows the gap without closing it. ## Speed [Section titled “Speed”](#speed) The benchmark uses two JSON baselines. **`JSON bytes`** encodes to and decodes from a `Uint8Array`. This is the direct comparison for binary transports. shorn wins every result: | Fixture | shorn enc | JSON enc | shorn dec | JSON dec | | ----------------- | ---------: | -------: | ---------: | -------: | | Person | **25.16M** | 4.74M | **67.55M** | 4.67M | | Unicode person | **7.74M** | 3.77M | **7.68M** | 3.58M | | Nested event | **8.64M** | 1.40M | **11.45M** | 1.74M | | 100-event batch | **100.1k** | 36.3k | **116.3k** | 21.2k | | Person, validated | **8.93M** | 3.69M | **12.07M** | 3.54M | Decode is where the record decoder shows: for Person, shorn is 14.5× `JSON bytes`, and the margin holds at 6.6× for the nested event and 5.5× for the batch. The encode margins are now 5.3× on Person and 6.2× on the nested event. **`JSON string`** stops at a JavaScript string and reaches 10.82M encodes/s for Person, against shorn’s 25.16M. It no longer beats shorn on any fixture in either direction. This baseline does less work because it never produces bytes, even though the size result reports UTF-8 byte length. Sending the string over a socket still requires that conversion. ### Text output was measured and rejected [Section titled “Text output was measured and rejected”](#text-output-was-measured-and-rejected) * **base64:** −31% throughput, +33% size. * **Latin-1 binary string:** −25% throughput, and inflates on the wire, since every byte above `0x7F` becomes two UTF-8 bytes when actually sent. Neither format matches the `JSON string` result because that baseline skips byte conversion. Base64 may still make sense for text-only transports because it is about 3× smaller than JSON in this test, but it is not faster. ## What JSON keeps [Section titled “What JSON keeps”](#what-json-keeps) * **Universal.** Every language, every tool, no schema needed. * **Inspectable.** `curl | jq` works; a shorn payload is opaque without its schema. * **No schema coupling.** A JSON payload outlives any schema version; a shorn payload does not. * **Streaming.** Incremental JSON parsers exist. * **Zero setup.** 0.08 µs cold against shorn’s 52–66 µs. ## When to switch [Section titled “When to switch”](#when-to-switch) **Switch when** payload size is a real cost (metered egress, mobile clients, high-volume queues, edge-to-origin), you already validate with Zod, Valibot, or ArkType, and both ends are TypeScript. **Stay with JSON** when you need cross-language readers, inspectable payloads, or streaming. JSON is also the simpler choice when payloads are too small or infrequent for the savings to matter. ## Migrating [Section titled “Migrating”](#migrating) ```ts // Before const body = JSON.stringify(person); const parsed = Person.parse(JSON.parse(text)); // After const body = encode(Person, person); const parsed = decode(Person, body); ``` Handle two migration details. First, `Date` and `bigint` fields need an explicit wire representation; see [rich types](/schemas/rich-types/). Second, set `Content-Type: application/octet-stream` because shorn has no registered media type. # vs Avro, Protobuf, SchemaPack > shorn ties or wins on size and leads every decode, while Avro leads encode and offers mature schema evolution. These three share shorn’s core idea — the schema is known out of band, so the payload need not describe itself. ## Size [Section titled “Size”](#size) | Codec | Person | Nested event | 100 events | | ----------- | -----: | -----------: | ---------: | | **shorn** | **8** | **43** | **4,135** | | Avro / avsc | **8** | 44 | 4,249 | | SchemaPack | 9 | 44 | 4,235 | | Protobuf.js | 11 | 57 | 5,684 | shorn wins or ties on every fixture, but the difference from Avro and SchemaPack is only 0–3%. **Size alone would not justify a new library.** Protobuf is 30% larger on the nested event because it includes field tags. Those tags add overhead but enable schema evolution. ## Speed [Section titled “Speed”](#speed) **shorn now leads both directions on every fixture but Unicode.** Avro was the last codec ahead of it on encode; generated record encoders and the framing work around them closed that. | Fixture | Op | shorn | Avro | SchemaPack | | ------------ | --- | ---------: | -----: | ---------: | | Person | enc | **25.16M** | 17.10M | 12.55M | | Person | dec | **67.55M** | 25.59M | 16.12M | | Nested event | enc | **8.64M** | 6.42M | 4.19M | | Nested event | dec | **11.45M** | 5.44M | 4.94M | | 100 events | enc | **100.1K** | 41.2K | 53.4K | | 100 events | dec | **116.3K** | 52.8K | 57.5K | Take the Person encode margin conservatively. Every codec in that table shares one process, and Avro’s Person encode read 21.06M, 20.72M and 21.69M before this change against 17.10M after it — with 20.74M when measured alone in its own process. Isolated single-codec runs on the same machine put shorn at 42.54 ns and Avro at 48.22 ns, a 13% lead rather than the 47% above. The other rows’ margins are wide enough that this does not reorder them. See [Throughput](/performance/throughput/). With validation on both ends shorn leads both directions, though validation cost dominates each: | Codec | Bytes | Encode | Decode | | ---------------- | ----: | --------: | ---------: | | shorn + Zod | **8** | **8.93M** | **12.07M** | | Zod + Avro | **8** | 8.47M | 10.10M | | Zod + SchemaPack | 9 | 7.00M | 7.90M | **There is no longer a measured encode gap to Avro.** The comparison below is kept only because it still bounds what a hand-written codec can do; it predates the allocation improvements, the generated encoders and the framing work, and has not been rerun. Raw Person encoding now takes about 40 ns instead of 204.7 ns. The hand-written codec keeps every check performed by the interpreter and produces identical bytes: | | Interpreted | Fair codegen | Win | | ------------- | ----------: | -----------: | -----------------: | | Person encode | 204.7 ns | 54.3 ns | **150.4 ns (73%)** | Both directions now compile: an object schema with no optional fields builds its own record decoder *and* record encoder with `new Function`. That plus three fixes to the framing around the schema walk — the pooled-writer hand-off, `finish()`, and a one-pass `Writer.string` — is what took every encode fixture but Unicode past Avro. What is left on the write path is the `Writer`, whose per-leaf method call the generated source does not yet inline. Both paths fall back to the interpreter under a Content Security Policy that forbids `new Function`. See [Throughput](/performance/throughput/). ## What each asks of you [Section titled “What each asks of you”](#what-each-asks-of-you) | Codec | What it requires | | -------------- | --------------------------------------------------------------------------------------------------------------------- | | **shorn** | You already use a Standard Schema validator | | **Avro** | A second schema model, kept in sync; Avro concepts leak into the API | | **Protobuf** | A `.proto` file, a compiler or reflection step, generated code, per-field tags. Also 187.75 µs cold and 25.93 KB gzip | | **SchemaPack** | A custom DSL, weak TS inference, a Node `Buffer` heritage needing a browser polyfill | shorn’s main difference is not size or speed. It is that **you do not maintain a second schema.** ## Evolution and cross-language: they win [Section titled “Evolution and cross-language: they win”](#evolution-and-cross-language-they-win) | Codec | Evolution | | ---------- | ----------------------------------------------------------------- | | Avro | Full reader/writer resolution. Mature, cross-language | | Protobuf | Field tags give append-only compatibility. Mature, cross-language | | SchemaPack | None | | shorn | Mismatch **detection** only | **Use Avro when you need automatic schema resolution.** It ties shorn on size by keeping the writer’s schema out of band. Reproducing Avro’s evolution model is outside shorn’s scope. See [Schema Evolution](/versioning/schema-evolution/). Avro and Protobuf have mature implementations in every language you need. shorn is TypeScript only — which is what lets it use JSON Schema instead of defining an IDL. ## Footprint [Section titled “Footprint”](#footprint) | Codec | Minified | Gzip | | ---------------- | -----------: | ----------: | | **shorn** | **17.57 KB** | **5.39 KB** | | protobufjs/light | 88.35 KB | 25.93 KB | `avsc` needs a browser `stream` polyfill and SchemaPack a `buffer` polyfill, so neither has a clean browser number. Cold setup: shorn 52–66 µs, Avro 68.99 µs, Protobuf.js 187.75 µs, SchemaPack 3.00 µs. ## Choosing [Section titled “Choosing”](#choosing) | Use | When | | -------------- | ---------------------------------------------------------------------------------------- | | **shorn** | TypeScript both ends, already validating, want no second schema and the smallest payload | | **Avro** | Cross-language, or real schema evolution | | **Protobuf** | Cross-language with an existing gRPC ecosystem | | **SchemaPack** | Node-only, raw speed, a custom DSL is acceptable | Generated formats — Bebop, FlatBuffers, Cap’n Proto — trade an IDL and compiler step for cross-language support and generated-code speed. Qualitative competitors until shorn has a specialized backend. # vs MessagePack, CBOR > Plain schemaless codecs carry field names and type tags. Record modes reduce that overhead by sharing structure out of band. MessagePack and CBOR are self-describing: a decoder can read a payload without its schema. That is often the right tradeoff, but their plain formats use about 3× as many bytes in these fixtures. ## Size [Section titled “Size”](#size) | Codec | Person | Nested event | 100 events | | ----------------------- | -----: | -----------: | ---------: | | **shorn** | **8** | **43** | **4,135** | | msgpackr shared records | 10 | 52 | 4,993 | | cbor-x shared records | 14 | 62 | 5,972 | | @msgpack/msgpack | 23 | 115 | 11,281 | | msgpackr plain | 25 | 121 | 11,881 | | cbor-x plain | 26 | 122 | 11,972 | shorn is 64% smaller than plain msgpackr for the nested event and 65% smaller for the batch. It is 17% smaller than msgpackr **shared records**, whose reported steady-state sizes exclude their structure table. ## How record modes compare [Section titled “How record modes compare”](#how-record-modes-compare) msgpackr shared records and cbor-x record extensions also move field names out of each payload. The difference is how that shared structure is defined and distributed. | | shorn | Shared records | | ------------------------- | ------------------------------------ | -------------------------------------------- | | Where key names live | your validation schema | a structure table both ends synchronize | | How it reaches the reader | you already deployed the schema | out-of-band state, built as records are seen | | On a mismatch | `DecodeError` with `fingerprinted()` | depends on table state | | Sizes quoted | standalone | steady-state, table excluded | Both approaches require shared structure outside the payload. With shorn, that structure is the validation schema you already deploy. ## Speed: shorn now leads both directions [Section titled “Speed: shorn now leads both directions”](#speed-shorn-now-leads-both-directions) | Fixture | Op | shorn | msgpackr records | | ------------ | --- | ---------: | ---------------: | | Person | enc | **25.16M** | 10.55M | | Person | dec | **67.55M** | 18.96M | | Nested event | dec | **11.45M** | 8.36M | | 100 events | dec | **116.3K** | 88.1K | shorn leads every fixture in both directions, including Unicode-heavy decoding (7.68M vs 3.97M). The nested event and batch used to be shorn’s largest performance gap — shared-record decoders were roughly 2× faster there. Generating a record decoder per object schema closed that gap and reversed it: shorn is now 1.37× faster on the nested event and 1.32× on the batch. See [Throughput](/performance/throughput/). msgpackr records still encode text-heavy payloads faster, but only barely (8.04M vs shorn’s 7.74M on the Unicode fixture) — the one fixture in either direction where shorn does not lead this table. ## Brotli reverses the size result [Section titled “Brotli reverses the size result”](#brotli-reverses-the-size-result) 100,000 repetitive events: | Codec | Raw | Gzip | Brotli q6 | | ---------------- | ------------: | ----------: | ----------: | | **shorn** | **4,231,777** | **924,494** | 603,189 | | msgpackr records | 4,995,339 | 1,114,738 | **513,630** | shorn is smaller raw and under gzip, but **17% larger under Brotli**. Brotli’s larger window compresses repeated metadata particularly well. If you store repetitive Brotli-compressed data, benchmark your own payloads. For the high-entropy fixture, shorn ranks first raw and under gzip, and second under Brotli. ## Bundle size [Section titled “Bundle size”](#bundle-size) | Codec | Minified | Gzip | | ---------------- | -----------: | ----------: | | **shorn** | **17.57 KB** | **5.39 KB** | | @msgpack/msgpack | 21.20 KB | 5.93 KB | | msgpackr | 27.59 KB | 10.39 KB | | cbor-x | 29.10 KB | 10.82 KB | Validation libraries are excluded from every row, and shorn assumes your application already ships one. msgpackr records also start much faster: 1.00 µs compared with shorn’s 52–66 µs. ## What they keep [Section titled “What they keep”](#what-they-keep) * **No schema needed to read.** A generic tool can inspect any payload. * **Broad value support.** cbor-x handles `Date`, `Map`, `Set`, `bigint` natively; shorn refuses all four ([rich types](/schemas/rich-types/)). * **Cross-language.** Both are standards with implementations everywhere; CBOR is an IETF standard. * **Streaming.** Both support incremental encode and decode. * **Encode speed on text.** msgpackr records still encode the Unicode fixture faster. * **Startup.** msgpackr records are ready in 1.00 µs against shorn’s 52–66 µs. ## Choosing [Section titled “Choosing”](#choosing) | Use | When | | -------------------- | ----------------------------------------------------------------------------------------------------------------- | | **shorn** | TypeScript both ends, already validating, smallest raw and gzip payloads matter | | **msgpackr** | You need `Date`/`Map`/`Set` natively, cold-start latency dominates, or you cannot guarantee a schema on both ends | | **@msgpack/msgpack** | Conservative standard MessagePack with broad interop | | **cbor-x** | You need CBOR specifically, usually for standards compliance | The main question is whether both endpoints have the schema. If they do, shorn can omit repeated structure from the payload. If they do not, use a self-describing format such as MessagePack. # Canonical Bytes > One value has exactly one encoding. Key order is derived rather than declared, so nothing can disagree about it. 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. ## Key order is derived [Section titled “Key order is derived”](#key-order-is-derived) Field order is the rank of the field’s name in **UTF-16 code-unit ascending order** — JavaScript’s default string comparison. ```ts 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](/wire-format/low-level-api/) cannot override it because canonical field order is a wire-format rule, not a schema option. ## Enum members are sorted too [Section titled “Enum members are sorted too”](#enum-members-are-sorted-too) ```ts 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](/versioning/fingerprinting/). ## Cross-vendor identity [Section titled “Cross-vendor identity”](#cross-vendor-identity) ```ts 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. ## Integers have one spelling [Section titled “Integers have one spelling”](#integers-have-one-spelling) 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. ## What canonicality does not cover [Section titled “What canonicality does not cover”](#what-canonicality-does-not-cover) * **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. ## Round-tripping is a fixed point [Section titled “Round-tripping is a fixed point”](#round-tripping-is-a-fixed-point) `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](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0003-rich-types-are-the-validators-job.md). # Compilation and Caching > encode and decode cache the wire plan by schema identity. compile exposes the same plan as an object, and generates a decoder function for objects that have no optional fields. Converting a JSON Schema to a wire plan takes 52–66 µs in the cold-start benchmark, including Zod schema construction. With a stable schema object, this happens once per schema rather than once per call. ## The functional API caches [Section titled “The functional API caches”](#the-functional-api-caches) ```ts const bytes = encode(Person, person); const back = decode(Person, bytes); ``` The plan is stored in a `WeakMap` keyed on the schema object’s identity, so a schema going out of scope takes its plan with it. **Measured:** the cached functional API reached 3.53M encodes/s, compared with 3.49M for `compiled.encode`. The difference is measurement noise, so use whichever API is clearer. ## `compile` returns the same plan [Section titled “compile returns the same plan”](#compile-returns-the-same-plan) ```ts const PersonWire = compile(Person); ``` Use it for a codec object to pass around, store in a map, or wrap in [`fingerprinted()`](/versioning/fingerprinting/). `codec` and `fromStandard` are aliases. **There is still no build step.** `compile` builds a tree of `Schema` objects in memory and writes nothing to disk. It does, however, generate one function at runtime — see below. ## One generated decoder per object schema [Section titled “One generated decoder per object schema”](#one-generated-decoder-per-object-schema) An object schema with **no optional fields** builds its record decoder with `new Function` when the codec is constructed, giving that schema a decode function of its own rather than a shared interpreted loop. Encoding is unaffected and still interpreted, as is any object that has an optional field. The generated function is not an optimisation of the loop so much as an escape from a V8 detail: feedback vectors are allocated per closure *creation site*, so one shared helper collects the hidden classes of every object schema in the program and goes megamorphic. Measured, a shared unrolled helper is 2.7× faster than the loop with one schema loaded and 3× *slower* once a dozen schemas share its call sites. Only a distinct function per schema keeps those sites monomorphic. **Schema keys are never parsed as code.** They are passed to the generated function as arguments and used as computed properties, so a key taken from a fetched JSON Schema cannot become executable source. This costs about 5% against interpolating them as string literals. ### Under a strict Content Security Policy [Section titled “Under a strict Content Security Policy”](#under-a-strict-content-security-policy) A policy without `unsafe-eval` makes `new Function` throw. shorn catches that and uses the interpreted decoder instead — no error, no configuration, identical bytes and identical decoded values, only slower. `test/core.test.ts` runs the whole path with `new Function` stubbed out to a throw and cross-decodes both directions to prove the two agree on the wire. ## Identity is what gets cached [Section titled “Identity is what gets cached”](#identity-is-what-gets-cached) ```ts // Cached: one plan, reused. const Person = z.object({ name: z.string() }); export const write = (p) => encode(Person, p); // Not cached: a new schema per call, so a new plan per call. export const write = (p) => encode(z.object({ name: z.string() }), p); ``` The second form pays the full conversion on every call. Hoist schemas to module scope. ## Valibot: two identities [Section titled “Valibot: two identities”](#valibot-two-identities) The cache is keyed on the schema **and** the structure object: ```ts // Cached. const structure = toStandardJsonSchema(Person); export const write = (p) => encode(Person, p, { structure }); // Not cached: toStandardJsonSchema returns a fresh object each call — and is // not free itself, so this form pays twice. export const write = (p) => encode(Person, p, { structure: toStandardJsonSchema(Person) }); ``` ## The `Writer` is pooled [Section titled “The Writer is pooled”](#the-writer-is-pooled) `encode` reuses one module-level `Writer` and resets it after every call, including when encoding throws. This has two effects: * **`encode` returns an exact-size copy**, not a view into an oversized buffer. Retained encode memory is 4.11 MiB for a 4.04 MiB payload, down from 12.10 MiB. * **Buffers grown past 64 KiB are released**, so one large encode does not permanently inflate the process. ## When cold setup matters [Section titled “When cold setup matters”](#when-cold-setup-matters) Cold setup is usually negligible in a long-lived server, but it can matter in a serverless function that handles only one request. For comparison: Avro takes 68.99 µs, Protobuf.js reflection 187.75 µs, SchemaPack 3.00 µs, msgpackr records 1.00 µs, and JSON 0.08 µs. Most of shorn’s time is Zod schema construction, which an application using Zod already pays. See [Footprint](/performance/footprint/). # How It Works > The four steps between a JavaScript value and shorn's bytes. ```plaintext value ──▶ validate ──▶ wire plan ──▶ bytes (Standard (Standard Schema) JSON Schema) ``` 1. **Your validator checks the value** through Standard Schema, including rules such as `.min(1)`, `.email()`, and `.refine()`. 2. **Standard JSON Schema supplies structure**: field names, types, optionality. shorn converts it to a wire plan once and caches it. 3. **The plan writes values** with no keys and no type tags. 4. **Decode runs in reverse**, and validates again with your original library. A payload that decodes structurally but fails a refinement is a `DecodeError`, never an accepted value. ## Two interfaces, two jobs [Section titled “Two interfaces, two jobs”](#two-interfaces-two-jobs) | Interface | Supplies | Used for | | -------------------- | ---------------------------------- | ---------------------------- | | Standard Schema | `validate(value)` | correctness, both directions | | Standard JSON Schema | `jsonSchema.input()` / `.output()` | structure | Both interfaces are vendor-neutral, so shorn does not need validator-specific code. This also creates its main limitation: **shorn cannot encode anything JSON Schema cannot describe.** See [Date, BigInt, Map, Set](/schemas/rich-types/). ## The wire plan [Section titled “The wire plan”](#the-wire-plan) The JSON Schema becomes a `WireShape` — a small closed union: ```plaintext boolean | float64 | int | string | uint | { array } | { tuple } | { object, rejectUnknown } | { enum } | { literal } | { nullable } ``` Two details drive the choices that matter: * **`type: "integer"` with `minimum >= 0`** becomes `uint` (plain varint); without it, `int` (ZigZag), which crosses every size boundary at half the value. * **`additionalProperties`** determines how extra fields are handled. `false` means the validator handles them. If the option is absent, shorn refuses extras during encoding. `true` or a schema makes the object open, which shorn does not support. Both `jsonSchema.input()` and `.output()` are converted and compared; a schema whose two sides differ needs a bidirectional codec and is refused. ## Compiled, then signed [Section titled “Compiled, then signed”](#compiled-then-signed) The `WireShape` becomes a tree of `Schema` objects — the same objects the [`m` API](/wire-format/low-level-api/) builds by hand, which is why the two produce identical bytes. Each node carries a `_minWidth`: the fewest bytes any value of that shape can occupy. That is what lets an array refuse an impossible element count *before* allocating. See [Hostile Input](/hostile-input/). shorn also stores a canonical string signature: the `WireShape` as JSON without `rejectUnknown`. [`fingerprinted()`](/versioning/fingerprinting/) hashes this signature. Removing `rejectUnknown` lets equivalent Zod and ArkType schemas agree even though they handle extra properties differently. The `m` API has no signature, so `fingerprinted()` refuses codecs built with `m`. ## Caching [Section titled “Caching”](#caching) `encode` and `decode` cache the plan in a `WeakMap` keyed by schema identity, so conversion runs once per schema. See [Compilation and Caching](/core-concepts/compile-and-caching/). # Validation > Your library validates on encode and again on decode. Throwing, result-returning, and async variants — and the one combination that does not compose. shorn runs your schema **both** ways: encode validates before writing bytes, decode validates the structurally-decoded value before returning it. ```ts const bytes = encode(Person, person); // validates, then writes const back = decode(Person, bytes); // reads, then validates ``` These checks are not redundant. The wire format knows that a field is a string; only your schema knows that it must be a non-empty email address under 64 characters. | Error | Thrown when | | ------------- | ---------------------------------------------------------------- | | `EncodeError` | validation failed on the way in, or the schema cannot be encoded | | `DecodeError` | the bytes are malformed, **or** validation failed on the way out | `DecodeError.offset` is the byte position reached. See [Errors](/api/errors/). ## Results instead of exceptions [Section titled “Results instead of exceptions”](#results-instead-of-exceptions) ```ts const result = safeDecode(Person, bytes); if (result.success) result.data; // typed else result.error; // always an Error, non-Error throws are wrapped ``` Use the safe variants at boundaries where malformed input is expected rather than exceptional. ## Async validation [Section titled “Async validation”](#async-validation) A schema with an async refinement cannot use `encode`/`decode` — they throw and say so. ```ts const bytes = await encodeAsync(Person, person); const back = await decodeAsync(Person, bytes); ``` Both take the **Standard Schema**, never a codec. There are no safe async variants — use `try`/`catch`. ### Async and `fingerprinted` do not compose [Section titled “Async and fingerprinted do not compose”](#async-and-fingerprinted-do-not-compose) `fingerprinted()` produces a codec, and no async entry point accepts one. A schema with an async refinement must choose. The reason is bundle size: class methods do not tree-shake. | import set | minified | gzip | | ------------------------------- | -------: | ----: | | `codec` | 17,389 | 5,383 | | + `m` | 17,147 | 5,314 | | + `safeEncode` / `safeDecode` | 17,435 | 5,382 | | + `encodeAsync` / `decodeAsync` | 17,748 | 5,456 | | + `fingerprinted` | 18,946 | 5,854 | | everything | 19,446 | 6,023 | Async support adds 313 minified and 74 gzip bytes, and only users who import it pay that cost. Moving async methods onto `Schema` would add those bytes for everyone, including users of only the low-level `m` API. See [ADR 0001](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0001-async-validation-stays-a-free-function.md). **Workaround:** carry `codec.fingerprintHex` separately, such as in a Kafka header or database column. Keep the payload bare and run async validation through the schema. ## Which entry point [Section titled “Which entry point”](#which-entry-point) | Situation | Use | | -------------------------------- | -------------------------------- | | Ordinary code | `encode` / `decode` | | Untrusted input | `safeEncode` / `safeDecode` | | Async refinement | `encodeAsync` / `decodeAsync` | | A codec to pass around | `compile` | | Stored, queued, version-crossing | `fingerprinted(compile(schema))` | All five use the same structural decode path through `Schema.decode`, so they report the same errors. `test/fuzz.test.ts` verifies this because a previous private path skipped the `Uint8Array` check and leaked a raw `TypeError` for invalid input. # Installation > Install shorn alongside any Standard Schema validator. Zod and ArkType need nothing extra; Valibot needs its JSON Schema converter. shorn has one dependency, `@standard-schema/spec`, which is types-only. ```sh npm install shorn zod # or npm install shorn arktype # or npm install shorn valibot @valibot/to-json-schema ``` shorn is ESM-only and has no Node built-ins. Its `neutral` platform target runs unchanged in browsers, workers, Bun, and Deno. ## Which validator needs what [Section titled “Which validator needs what”](#which-validator-needs-what) shorn uses Standard Schema for validation and Standard JSON Schema for structure. Some validators provide both interfaces on one object; others need an extra converter. | Validator | Version | Extra package | Call style | | ------------------------------- | ------- | ------------------------- | -------------------------------------- | | [Zod](/validators/zod/) | 4.2+ | none | `encode(Person, value)` | | [ArkType](/validators/arktype/) | 2.1.28+ | none | `encode(Person, value)` | | [Valibot](/validators/valibot/) | 1.x | `@valibot/to-json-schema` | `encode(Person, value, { structure })` | Any other validator that implements both interfaces works without an adapter. ## Requirements [Section titled “Requirements”](#requirements) * **Node 22+**, or any runtime with `TextEncoder`, `DataView`, and `Uint8Array`. * **TypeScript 5.x** for typed results — `decode` returns your schema’s type instead of `unknown`. * **ESM.** There is no CommonJS build. ## Verify [Section titled “Verify”](#verify) ```ts const Person = z.object({ name: z.string(), age: z.int().nonnegative() }); const bytes = encode(Person, { name: "Ada", age: 36 }); bytes.length; // 5 decode(Person, bytes); // { name: "Ada", age: 36 } ``` An `EncodeError` here means the schema uses an unsupported shape. [Rejected Shapes](/schemas/rejected-shapes/) lists each unsupported shape and what to use instead. # Introduction > shorn is compact binary serialization for Zod, Valibot, and ArkType. It reads the schema you already validate with and writes payloads without keys or type tags. shorn encodes data with the schema your project already uses. It reads [Standard Schema](https://standardschema.dev/schema) for validation and [Standard JSON Schema](https://standardschema.dev/json-schema) for structure. Field names and type tags stay in the schema instead of being repeated in every payload. ```ts import { z } from "zod"; import { decode, encode } from "shorn"; const Person = z.object({ name: z.string(), age: z.int().nonnegative(), sex: z.enum(["M", "F", "X"]), }); const bytes = encode(Person, person); // 8 bytes const decoded = decode(Person, bytes); ``` The same value is 35 bytes of minified JSON. ## What you get [Section titled “What you get”](#what-you-get) * **No shorn schema language.** No IDL, CLI, codegen, or compiler plugin. * **Canonical bytes.** Equivalent Zod, Valibot, and ArkType schemas encode identically. * **Validation both ways.** Your library runs on encode and again on decode. * **5.39 KB gzip** runtime, the smallest codec in the measured comparison, tree-shaken per feature. * MIT licensed. ## What you do not get [Section titled “What you do not get”](#what-you-do-not-get) shorn is an experimental alpha with important limits: * **No schema evolution.** Only the schema that wrote a payload can decode it. [`fingerprinted()`](/versioning/fingerprinting/) detects a mismatch; nothing resolves one. * **No streaming**, random access, or zero-copy views. * **No cross-language decoder.** TypeScript and JavaScript only. * **Not the fastest.** Avro encodes faster. msgpackr shared records decode nested and batch data faster. shorn beats JSON in both directions on every measured fixture; see [Throughput](/performance/throughput/). * **Not confidential.** Encrypt the bytes when secrecy matters. ## Where next [Section titled “Where next”](#where-next) | To | Read | | ----------------------- | -------------------------------------------------------------------------------------------- | | Understand the pitch | [Why shorn?](/getting-started/why-shorn/) | | Get running | [Installation](/getting-started/installation/), [Quick Start](/getting-started/quick-start/) | | Know what encodes | [Supported Types](/schemas/supported-types/) | | Store or queue payloads | [Fingerprinting](/versioning/fingerprinting/) — not optional | | See the bytes | [Byte Layout](/wire-format/layout/) | # Philosophy > The rules shorn holds itself to, each with what it gives up. Each rule below comes with a tradeoff. **The schema is the only schema.** Standard JSON Schema provides structure, while Standard Schema provides validation. Both interfaces are vendor-neutral, so shorn does not need validator-specific code. *Tradeoff:* shorn cannot encode values that JSON Schema cannot describe, including `Date`, `bigint`, `Map`, and `Set`. See [rich types](/schemas/rich-types/). **Bytes are canonical and derived.** Fields are ordered by their names in UTF-16 order. Because the order is derived, schemas and validators cannot disagree about it. *Tradeoff:* you cannot choose the field order. The low-level `m` API cannot override it either. **No self-description.** Payloads contain no field names, type tags, overall length, or version byte. *Tradeoff:* shorn cannot evolve schemas automatically. Use [`fingerprinted()`](/versioning/fingerprinting/) to detect a mismatch; 26.7% of near-miss schemas otherwise decode to the wrong value without an error. **Detect, do not resolve.** shorn reports a schema mismatch but does not reconcile two versions. Adding field tags would enable that, but would make payloads larger. *Tradeoff:* applications must keep historical codecs and select one by fingerprint. See [ADR 0002](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0002-no-schema-evolution-only-mismatch-detection.md). **Only imported features add bundle size.** Async validation is a free function rather than a `Schema` method because class methods do not tree-shake. It adds 450 minified bytes only when imported. *Tradeoff:* async validation does not compose with `fingerprinted()`. See [ADR 0001](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0001-async-validation-stays-a-free-function.md). **Invalid data fails explicitly.** Unknown properties are refused, overlong varints are rejected, and trailing bytes cause an error. Invalid UTF-8 is fatal. Impossible array counts are rejected before allocation. `DecodeError` includes a byte offset. *Tradeoff:* schemas that allow arbitrary keys fail during codec construction. In fixed schemas, unexpected properties fail during encoding instead of being dropped silently. **Claims are measured.** Every number in these docs comes from a benchmark in the repository. Changes that do not improve the measurements are rejected, including presizing the `Writer`, using indexed loops, and creating a lazy `DataView`. *Tradeoff:* claims stay narrow. shorn is neither the fastest codec nor the smallest after every compression method. **Round trips preserve representation.** `decode(encode(x))` returns `x`, not merely an equivalent value. shorn does not automatically convert ISO-8601 timestamps to epoch integers because the decoded string might differ from the original spelling. *Tradeoff:* an ISO-8601 timestamp uses about 20 more bytes than an epoch integer. Applications can choose the smaller representation explicitly. # Quick Start > Encode and decode functionally, cache a codec with compile, add schema identity with fingerprinted. ## 1. The functional API [Section titled “1. The functional API”](#1-the-functional-api) ```ts import { z } from "zod"; import { decode, encode } from "shorn"; const Person = z.object({ name: z.string(), age: z.int().nonnegative(), sex: z.enum(["M", "F", "X"]), }); const person = { name: "Grace", age: 45, sex: "F" } as const; const bytes = encode(Person, person); // Uint8Array(8) [45, 5, 71, 114, 97, 99, 101, 0] const back = decode(Person, bytes); ``` The complete record is eight bytes. `45` is the age. `5` is the byte length of `"Grace"`, followed by its five ASCII bytes. `0` is the index of `"F"` in the sorted enum. [Byte Layout](/wire-format/layout/) explains why `age` comes first. The wire plan is cached by schema identity: 3.53M encodes/s against 3.49M for `compiled.encode` — noise. ## 2. The compiled API [Section titled “2. The compiled API”](#2-the-compiled-api) ```ts import { compile } from "shorn"; const PersonWire = compile(Person); PersonWire.encode(person); PersonWire.decode(bytes); ``` Use this form when you want a codec object to pass around. It is not faster than the functional API — both build and cache the same codec at runtime, with no build step and no generated file on disk. ## 3. Schema identity [Section titled “3. Schema identity”](#3-schema-identity) Bare payloads contain no schema identifier. If you decode them with the wrong schema, only 58% are rejected in the mismatch test. **About 27% decode to the wrong value without an error.** ```ts import { fingerprinted } from "shorn"; const PersonWire = fingerprinted(compile(Person)); const bytes = PersonWire.encode(person); // 11 bytes: 3 fingerprint + 8 payload PersonWire.decode(bytes); // throws if the schema changed ``` **Use `fingerprinted` for anything stored, queued, or sent across a version boundary.** Use bare payloads only when both endpoints are pinned to one schema at deploy time. See [Fingerprinting](/versioning/fingerprinting/) for the tradeoff. ## Errors without exceptions [Section titled “Errors without exceptions”](#errors-without-exceptions) ```ts const result = safeDecode(Person, bytes); if (!result.success) return new Response("Bad request", { status: 400 }); result.data; // typed ``` ## Valibot takes one extra argument [Section titled “Valibot takes one extra argument”](#valibot-takes-one-extra-argument) ```ts import * as v from "valibot"; import { toStandardJsonSchema } from "@valibot/to-json-schema"; const PersonWire = compile(Person, { structure: toStandardJsonSchema(Person) }); ``` Zod 4.2+ and ArkType 2.1.28+ need no second argument. See [Valibot](/validators/valibot/). ## Next [Section titled “Next”](#next) [How It Works](/core-concepts/how-it-works/) · [Supported Types](/schemas/supported-types/) · [Fingerprinting](/versioning/fingerprinting/) # Why shorn? > The schema is the redundancy. Self-describing formats repeat it in every payload; shorn reads it instead. Many formats describe the same payload twice: once in the schema and again through field names and type tags in the bytes. Self-describing formats need that information because they can decode without a schema. If both endpoints already use the same validation schema, shorn can leave it out of the payload. ## Against JSON [Section titled “Against JSON”](#against-json) ```ts // JSON: field names travel with every record. const bytes = new TextEncoder().encode(JSON.stringify(person)); // 35 bytes // shorn: field names stay in the schema. const bytes = encode(Person, person); // 8 bytes ``` Compared with JSON converted to and from a `Uint8Array`, shorn is 77% smaller for this record and faster in both directions on every measured fixture: | Fixture | shorn enc | JSON enc | shorn dec | JSON dec | | ----------------- | --------: | -------: | --------: | -------: | | Person | 25.16M | 4.74M | 67.55M | 4.67M | | Unicode person | 7.74M | 3.77M | 7.68M | 3.58M | | Nested event | 8.64M | 1.40M | 11.45M | 1.74M | | 100-event batch | 100.1k | 36.3k | 116.3k | 21.2k | | Person, validated | 8.93M | 3.69M | 12.07M | 3.54M | shorn is 2.1–6.2× faster to encode and 2.1–14.5× faster to decode. It now wins all ten comparisons against `JSON.stringify`, even though that baseline stops at a string instead of producing bytes. Sending the string over a socket still requires a byte conversion, whether your code or the platform performs it. ## Against the other binary codecs [Section titled “Against the other binary codecs”](#against-the-other-binary-codecs) The formats that beat shorn on speed require a different tradeoff: | Codec | What it requires | | ------------------ | -------------------------------------------------------------------- | | Avro | A second schema model, kept in sync with your validator | | Protobuf | A `.proto` file, a compiler step, and per-field tags on every record | | SchemaPack | A custom DSL, weak TS inference, Node `Buffer` heritage | | msgpackr records | Out-of-band record state both ends must synchronize | | MessagePack / CBOR | No schema, but payloads are about 3× larger in these fixtures | shorn’s ask is that you already use a Standard Schema validator. ## Tradeoff summary [Section titled “Tradeoff summary”](#tradeoff-summary) | Axis | Result | | -------------- | ---------------------------------------------------------------- | | Raw size | Best or tied in every fixture | | Gzip size | Best in both 100,000-event profiles | | Brotli size | Sixth on repetitive data, second on high-entropy | | Bundle | 5.39 KB gzip, smallest measured; 9% margin over /msgpack | | Speed | Fastest measured in both directions on every fixture but Unicode | | Cold setup | 52–66 µs including Zod schema construction | | Evolution | None. Detection only | | Cross-language | TypeScript only | The supported claim is: *small TypeScript-native payloads from the validator you already use, with no schema language, generator, or build step*. shorn is not the smallest after every compressor; Brotli can make msgpackr records or Protobuf smaller. It leads the measured field in both directions now, but only on these fixtures, on one machine, against these versions. ## When not to use it [Section titled “When not to use it”](#when-not-to-use-it) * **Cross-language readers** → Avro or Protobuf. * **Evolution with resolution, not detection** → Avro. * **Streaming or random access** → shorn encodes whole values. * **You are not validating** → shorn has no schema to use; choose a self-describing format such as MessagePack. * **Both ends pinned and size is not a cost** → JSON is universal and inspectable. # Hostile Input > What the decoder checks, what it bounds, and which claims shorn does not yet make about malicious bytes. A tagless decoder relies on the schema to interpret every byte. Bounds and length checks are therefore essential when payloads are untrusted. ## What is checked [Section titled “What is checked”](#what-is-checked) | Check | Behavior | | ------------------------------------- | -------------------------------------------- | | Read past end of input | `DecodeError` with byte offset | | Trailing bytes after a complete value | `DecodeError` | | Non-canonical (overlong) varint | `DecodeError` | | Varint beyond the safe integer range | `DecodeError` | | Invalid UTF-8 | `DecodeError` — fatal, not replacing | | Boolean byte other than 0 or 1 | `DecodeError` | | Enum index past the last member | `DecodeError` | | Unknown object property | `EncodeError` on the way in | | Input that is not a `Uint8Array` | `DecodeError`, not a raw `TypeError` | | `__proto__` as a decoded key | handled; the decode target is null-prototype | The suite covers every truncated prefix of a valid payload, trailing data, invalid booleans and enum indexes, invalid UTF-8, over-limit array lengths, non-canonical varints, mutable schema declarations, open-object rejection, unknown properties, and `__proto__`. Property-based round-trip and mutation tests are in `test/fuzz.test.ts`. Hard limits are **1,000,000** collection elements and **64 MiB** for strings or byte arrays. These are backstops; the input-length checks below provide the main allocation defense. ## Allocation is bounded by input length, not schema shape [Section titled “Allocation is bounded by input length, not schema shape”](#allocation-is-bounded-by-input-length-not-schema-shape) A naive decoder can allocate far more memory than the payload size suggests. A seven-byte payload can declare an array with one million elements, and nested arrays can multiply that allocation. Every schema carries a **`_minWidth`**, the fewest bytes one value can occupy. Before allocating an array, the decoder multiplies this width by the declared count and checks that enough input remains. | Payload | Before | After | | ------------------------------------- | ------------------------------ | -------------------------------- | | 7 bytes, ordinary nested-array schema | 16.0 MB allocated, 2,287,431:1 | 0.01 MB, rejected at byte 3 | | Same payload, decodes/s | 809 | 227,596 | | 3 bytes, `array(literal)` | 1,000,000 elements | refused when the schema is built | Because `_minWidth` is computed during codec construction, the runtime check adds one multiplication per decoded array. This is why **arrays of zero-width elements are rejected during codec construction**. Literals, empty tuples, and empty objects use no bytes, so the decoder could not verify the declared count against the payload length. A tuple may still contain them because its length comes from the schema. ## Encode memory [Section titled “Encode memory”](#encode-memory) `encode` returns an **exact-size copy** instead of a view into an oversized buffer. It also releases internal buffers larger than 64 KiB. Encoding a 4.04 MiB payload now retains 4.11 MiB, down from 12.10 MiB. ## What shorn does not yet claim [Section titled “What shorn does not yet claim”](#what-shorn-does-not-yet-claim) Listed so you can judge the risk rather than infer safety from silence: * **No coverage-guided fuzzing.** Property-based and mutation tests exist; a coverage-guided fuzzer does not. * **No depth limit.** A schema nested about 5,900 levels deep can exhaust the JavaScript stack and throw `RangeError` instead of `DecodeError`. This requires a hostile **schema**, not merely hostile bytes. Limit depth if schemas come from untrusted input. * **No peak-allocation or allocation-rate profiling.** Retained memory is measured; transient churn is not. * **No validation-failure throughput or adversarial compression tests.** * **No browser-runtime matrix.** Bundling is measured; execution is not. ## Practical guidance [Section titled “Practical guidance”](#practical-guidance) **Use `safeDecode` at untrusted boundaries**, where malformed input should be handled as normal traffic. ```ts const result = safeDecode(Person, bytes); if (!result.success) return new Response("Bad request", { status: 400 }); ``` **Use `fingerprinted` for stored and queued payloads.** It is not a security feature because the digest is unkeyed and forgeable. It prevents schema mismatches from silently producing incorrect data. **Encrypt when secrecy matters.** Compact is not confidential. **Cap payload size at the transport.** The 64 MiB limit is a backstop, not a policy. **Do not treat the fingerprint as authentication.** Sign or encrypt if you need authenticity. # Footprint > Bundle size, cold setup, and memory. The smallest measured browser runtime at 5.39 KB gzip. ## Bundle size [Section titled “Bundle size”](#bundle-size) These results measure an esbuild-minified browser bundle for each imported codec API. Validation libraries are excluded from every row. | Codec | Minified | Gzip | | --------------------------------- | -----------: | ----------: | | **shorn core + Standard adapter** | **17.57 KB** | **5.39 KB** | | @msgpack/msgpack | 21.20 KB | 5.93 KB | | msgpackr | 27.59 KB | 10.39 KB | | cbor-x | 29.10 KB | 10.82 KB | | protobufjs/light | 88.35 KB | 25.93 KB | shorn is the smallest codec measured, but **the gzip margin over `@msgpack/msgpack` is now 9%, not the 27% earlier revisions reported.** Size is a lead worth defending, not a settled one. `avsc` needs a browser `stream` polyfill, and SchemaPack needs a `buffer` polyfill, so the harness does not report a zero-polyfill result for either. The shorn npm tarball is 41,432 bytes, of which the source map is roughly two thirds; it is published for debugging and never reaches a bundle. ### It tree-shakes per feature [Section titled “It tree-shakes per feature”](#it-tree-shakes-per-feature) | import set | minified | gzip | this row adds | | ------------------------------- | -------: | ----: | ------------: | | `codec` | 17,389 | 5,383 | — | | + `m` | 17,969 | 5,523 | 140 gzip | | + `safeEncode` / `safeDecode` | 18,257 | 5,594 | 71 gzip | | + `encodeAsync` / `decodeAsync` | 18,570 | 5,666 | 72 gzip | | + `fingerprinted` | 19,768 | 6,061 | 395 gzip | | everything | 20,268 | 6,232 | 171 gzip | **Only users who import a feature pay for it.** Fingerprinting is the most expensive single import at 395 gzip bytes, and a bundle that never calls `fingerprinted()` never carries it. Two structural decisions keep that true: * **Async validation is a free function, not a `Schema` method**, because class methods do not tree-shake — every method on `Schema` is retained by every bundle that touches a codec. See [ADR 0001](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0001-async-validation-stays-a-free-function.md). * **The Standard Schema compiler constructs wire schemas directly rather than through `m`.** Referencing `m` pinned the whole builder object into every bundle, including `bytes` and `float32`, which no JSON Schema type can ever select. Removing that reference took 607 minified and 145 gzip bytes off a `codec`-only import. The one place tree-shaking stops is `m` itself: it is a single object, so `import { m }` retains all twelve builders whether you call two or twelve. That is a deliberate trade — see [ADR 0004](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0004-m-stays-one-object.md). Add your validator on top. shorn does not ship one, which is the point — you were already paying for Zod. ## Cold setup [Section titled “Cold setup”](#cold-setup) Schema and codec construction plus the first Person encode. | Codec | Cold setup | | ---------------------- | -----------: | | JSON | 0.08 µs | | msgpackr records | 1.00 µs | | SchemaPack | 3.00 µs | | **shorn + Zod** | **52–66 µs** | | Avro / avsc | 68.99 µs | | Protobuf.js reflection | 187.75 µs | shorn starts faster than Avro but slower than SchemaPack. **Most of shorn’s time is Zod schema construction**, which applications using Zod already pay. The cost is usually negligible in a long-lived server but can matter in a serverless function that handles one request. Define schemas at module scope so warm invocations reuse them. ## Memory [Section titled “Memory”](#memory) Steady-state retained memory after repeated forced GC in isolated processes, for 100,000 decoded events. Does **not** measure transient peak allocation. | Codec | Payload | Encode retained | Decoded value | RSS increase | | ---------------- | -----------: | --------------: | ------------: | ------------: | | shorn | **4.04 MiB** | **4.11 MiB** | 36.66 MiB | 72.80 MiB | | Avro | 4.14 MiB | 4.20 MiB | 33.64 MiB | 61.22 MiB | | SchemaPack | 4.13 MiB | 4.17 MiB | 47.82 MiB | 64.17 MiB | | msgpackr records | 4.76 MiB | 17.05 MiB | 32.44 MiB | **62.20 MiB** | | JSON | 15.58 MiB | 15.58 MiB | **24.03 MiB** | 88.69 MiB | **Encoding a 4.04 MiB payload retains 4.11 MiB.** This is down from 12.10 MiB before encoded output became an exact-size copy. Retaining the result no longer retains a larger backing buffer, and internal buffers larger than 64 KiB are released. These rules also make it safe to reuse one pooled `Writer`. Decoded memory is middle of pack, RSS below JSON but above Avro and msgpackr records. ## Runtime portability [Section titled “Runtime portability”](#runtime-portability) shorn targets `es2022` with esbuild’s `neutral` platform setting. It is ESM-only and uses no Node built-ins, so it runs in Node 22+, Bun, Deno, browsers, and workers. Nothing in the library needs Node 22 specifically — the floor is set by what CI can prove, and the pinned pnpm no longer runs on Node 20. The full comparison and a smoke test also ran under **Bun 1.3.14**. Rankings changed between runtimes, so publish runtime-specific numbers with their runtime and version. There is no browser *execution* matrix yet — only bundling is measured; see [Hostile Input](/hostile-input/) for every unproven claim. ## Reproducing [Section titled “Reproducing”](#reproducing) ```sh pnpm bench:bundle # bundle sizes per import set pnpm bench:startup # cold setup pnpm bench:memory # retained memory in isolated processes ``` # Payload Size > Smallest raw payload in every measured fixture, smallest gzip in both large profiles, sixth under Brotli on repetitive data. Size is the axis shorn leads. Every number is from `pnpm bench:all` on Node v22.23.1, Apple M4 Pro, macOS arm64. ## Raw bytes [Section titled “Raw bytes”](#raw-bytes) | Codec | Person | Unicode person | Nested event | 100 events | | ----------------------- | -----: | -------------: | -----------: | ---------: | | **shorn** | **8** | **31** | **43** | **4,135** | | Avro / avsc | **8** | **31** | 44 | 4,249 | | SchemaPack | 9 | 32 | 44 | 4,235 | | msgpackr shared records | 10 | 33 | 52 | 4,993 | | Protobuf.js reflection | 11 | 34 | 57 | 5,684 | | cbor-x shared records | 14 | 38 | 62 | 5,972 | | @msgpack/msgpack | 23 | 46 | 115 | 11,281 | | msgpackr plain | 25 | 48 | 121 | 11,881 | | cbor-x plain | 26 | 50 | 122 | 11,972 | | JSON | 35 | 58 | 163 | 16,148 | shorn is smallest or tied on every fixture. Two caveats apply: bare shorn, Avro, and Protobuf payloads require the correct schema outside the payload, and shared-record sizes exclude their record table. The Unicode row shows where the savings come from. shorn removes field names, tags, and syntax, but it does not shrink string content. Payloads dominated by structure and numbers can be about 75% smaller than JSON. Payloads dominated by free text see smaller gains. ## Compressed, 100,000 events [Section titled “Compressed, 100,000 events”](#compressed-100000-events) ### Repetitive data [Section titled “Repetitive data”](#repetitive-data) | Codec | Raw | Gzip | Brotli q6 | | ---------------- | ------------: | ----------: | ----------: | | **shorn** | **4,231,777** | **924,494** | 603,189 | | SchemaPack | 4,331,777 | 937,975 | 597,776 | | Avro | 4,344,802 | 935,333 | 632,034 | | msgpackr records | 4,995,339 | 1,114,738 | **513,630** | | Protobuf.js | 5,781,774 | 980,559 | 618,608 | | JSON | 16,340,686 | 1,474,952 | 985,919 | shorn is smallest raw and under gzip. **It is not smallest under Brotli:** msgpackr records are 17% smaller, and SchemaPack is slightly smaller. Brotli compresses their repeated metadata particularly well. Compression CPU for the shorn payload: gzip 78.99 ms, gunzip 4.12 ms, Brotli q6 42.19 ms, unbrotli 5.25 ms. Brotli was smaller *and* faster here, which reverses below. ### High-entropy data [Section titled “High-entropy data”](#high-entropy-data) | Codec | Raw | Gzip | Brotli q6 | | ---------------- | ------------: | ------------: | ------------: | | **shorn** | **6,987,333** | **2,337,080** | 2,084,918 | | SchemaPack | 7,087,333 | 2,540,070 | 2,089,259 | | Avro | 7,100,358 | 2,446,369 | 2,187,026 | | msgpackr records | 7,750,895 | 2,598,288 | 2,235,235 | | Protobuf.js | 8,487,330 | 2,508,528 | **1,943,144** | | JSON | 18,996,242 | 3,001,786 | 2,580,139 | shorn is smallest raw and under gzip, and second under Brotli. Compared with JSON, it is 63% smaller raw, 22% smaller under gzip, and 19% smaller under Brotli. Protobuf compresses best under Brotli in this fixture despite being 21% larger raw. Compression CPU: gzip 111.90 ms, gunzip 8.29 ms, Brotli q6 163.15 ms, unbrotli 12.47 ms. ## What not to claim [Section titled “What not to claim”](#what-not-to-claim) Do not claim that shorn is smallest under every compressor. Brotli can make msgpackr records or Protobuf smaller than shorn. The measured claim is narrower: shorn is smallest raw and under gzip in both 100,000-event profiles. ## Cutting bytes further [Section titled “Cutting bytes further”](#cutting-bytes-further) * **Declare non-negative integers.** ZigZag doubles the magnitude, so an `int` crosses every varint boundary at half the value. * **Use enums, not free strings**, for closed sets — one varint index against length plus content. * **Prefer literals** where a field is constant: zero bytes. * **Choose a compact timestamp form.** ISO-8601 is \~25 bytes, epoch millis 6–7. shorn will not choose for you — see [rich types](/schemas/rich-types/). * **Ask whether every payload needs the fingerprint.** It is 3 bytes: keep it for stored data; for pinned RPC, or when `fingerprintHex` rides in a header, the payload can stay bare. ## Reproducing [Section titled “Reproducing”](#reproducing) ```sh pnpm bench # small fixtures pnpm bench:large # 100,000 repetitive events pnpm bench:entropy # 100,000 high-entropy events pnpm bench:all # everything, plus correctness checks ``` Before quoting these results externally, regenerate every table with one `bench:all` run. Do not combine rows from different runs or machines. # Throughput > Fastest measured encode and decode on every fixture but Unicode, and faster than JSON both ways everywhere. **shorn leads both directions on every fixture except Unicode.** Generated record encoders plus three fixes to the framing around them took Person encode past Avro, which was the last codec ahead of it. Unicode is the one holdout: Avro still leads its decode and msgpackr’s shared records its encode, both bound by `TextEncoder`/`TextDecoder` rather than by dispatch. ## Against JSON [Section titled “Against JSON”](#against-json) `JSON bytes` converts to and from a `Uint8Array`, making it the direct comparison for binary transports. shorn wins every result while using 23–26% as many bytes for the ASCII fixtures and 53% for the Unicode fixture: | Fixture | shorn enc | JSON enc | shorn dec | JSON dec | | ----------------- | ---------: | -------: | ---------: | -------: | | Person | **25.16M** | 4.74M | **67.55M** | 4.67M | | Unicode person | **7.74M** | 3.77M | **7.68M** | 3.58M | | Nested event | **8.64M** | 1.40M | **11.45M** | 1.74M | | 100-event batch | **100.1k** | 36.3k | **116.3k** | 21.2k | | Person, validated | **8.93M** | 3.69M | **12.07M** | 3.54M | That is 2.1–6.2× encode and 2.1–14.5× decode. Even with Zod validation on both sides, shorn decodes 3.4× faster than JSON does with no validation at all. `JSON.stringify` to a *string* reaches 10.82M/s for Person against shorn’s 25.16M/s, and stops at a JavaScript string instead of producing bytes. See [shorn vs JSON](/comparisons/json/). ## Against the compiled schema codecs [Section titled “Against the compiled schema codecs”](#against-the-compiled-schema-codecs) | Fixture | Op | shorn | Avro | SchemaPack | msgpackr records | | -------------- | --- | ---------: | --------: | ---------: | ---------------: | | Person | enc | **25.16M** | 17.10M | 12.55M | 10.55M | | Person | dec | **67.55M** | 25.59M | 16.12M | 18.96M | | Unicode person | enc | 7.74M | 5.77M | 6.98M | **8.04M** | | Unicode person | dec | 7.68M | **9.93M** | 8.25M | 3.97M | | Nested event | enc | **8.64M** | 6.42M | 4.19M | 3.71M | | Nested event | dec | **11.45M** | 5.44M | 4.94M | 8.36M | | 100 events | enc | **100.1K** | 41.2K | 53.4K | 26.9K | | 100 events | dec | **116.3K** | 52.8K | 57.5K | 88.1K | shorn now leads every column here except the two Unicode ones, where Avro leads decode and msgpackr’s shared records lead encode by 4%. Read the Person encode margin conservatively Avro’s Person encode reads 17.10M here, against 21.06M, 20.72M and 21.69M in three runs before this change — and 20.74M when measured **alone in its own process**. Every codec in this table shares one process, and making shorn’s encoder allocate less appears to have shifted the environment the codecs measured after it run in. The defensible claim is the isolated one: single-codec processes on the same machine put shorn at 42.54 ns and Avro at 48.22 ns for Person encode, a **13% lead**, not the 47% this table implies. The other fixtures’ margins are wide enough that the effect does not change their ordering. Note Regenerated after the encode framing work, on the machine listed under [Methodology](#methodology). Encode moved and decode did not: Person encode was 19.09M and is now 25.16M, nested 6.80M to 8.64M, the batch 76.8K to 100.1K. Payload sizes did not change, and no decode result moved beyond noise. ## Validated end to end [Section titled “Validated end to end”](#validated-end-to-end) | Codec | Bytes | Encode | Decode | | ----------------- | ----: | --------: | ---------: | | **shorn + Zod** | **8** | **8.93M** | **12.07M** | | Zod + Avro | **8** | 8.47M | 10.10M | | Zod + SchemaPack | 9 | 7.00M | 7.90M | | Zod + JSON string | 35 | 6.42M | 4.09M | | Zod + JSON bytes | 35 | 3.69M | 3.54M | With validation on both sides, shorn is about 2.42× faster to encode and 3.41× faster to decode than `JSON bytes`, while using 77% fewer bytes. **This is now the fastest validated result measured in both directions**, ahead of Avro by 5% on encode and 19% on decode. Validation dominates both directions: the raw codec encodes at 25.16M/s and the validated path at 8.93M/s, so about 72 ns of the 112 ns is Zod. On decode it is 68 ns of the 83 ns. Further codec work moves an ever smaller share of what a validated round trip actually costs. ## Why the codecs are fast now [Section titled “Why the codecs are fast now”](#why-the-codecs-are-fast-now) An object schema with no optional fields builds its record decoder — and, since the encode-side counterpart landed, its record encoder — with `new Function` when the codec is constructed, so each such schema gets a function of its own instead of sharing an interpreted loop. That is what moved Person decode from 23.66M to 64.52M, and Person encode from 14.20M to 19.09M. The win is not loop overhead. V8 allocates a feedback vector per closure **creation site**, so one shared helper accumulates the hidden classes of every object schema in the program and goes megamorphic — measured, a shared unrolled helper reads 2.7× faster than the loop with a single schema loaded and 3× *slower* once a dozen schemas share its call sites. Only a distinct function per schema keeps those call sites monomorphic. The two sides attach that function differently, and the difference is not cosmetic. The decoder is shadowed onto the instance; the encoder is held in a field and dispatched from the prototype method. Shadowing `_encode` too gives every object schema a distinct function at the shared `this.item._encode(...)` call site inside `ArraySchema`, which tips it megamorphic — measured at **−25% on an array of plain uints**, a shape that contains no object schema at all. Routing through one prototype method costs about 7% of the object-encode win and hands the rest of the program its inline caches back. Schemas with optional fields keep the interpreted path, because the presence bitmap makes the field set dynamic. Encoding additionally keeps it for schemas that reject unknown properties or carry a key shadowing `Object.prototype`, both of which need work before the field loop. A strict Content Security Policy falls back to the same path, with identical bytes and identical results. See [Compilation and Caching](/core-concepts/compile-and-caching/). ## Most of a small encode was never the schema [Section titled “Most of a small encode was never the schema”](#most-of-a-small-encode-was-never-the-schema) Generated encoders were worth 33% on Person and then stalled. Decomposing what remained found that `_encode` — the whole schema walk, every field, every leaf — was **25 ns of a 73 ns Person encode**. The other 48 ns was the framing around it, and three fixes took Person from 19.09M to 25.16M: | Cost | Was | Fix | Worth | | ---------------------- | ---------------------------------------------------- | -------------------------------- | ------------------------: | | Pooled-writer hand-off | take the module-level Writer, store `undefined` back | a boolean `busy` flag | 12.5 ns | | `finish()` | `buffer.slice(0, offset)` | allocate-and-copy below 16 bytes | \~7 ns | | `Writer.string` | one walk to total the UTF-8 length, a second to copy | one speculative walk | 7.6 ns on a 3-char string | Each is worth recording for a different reason. **The pooled writer.** Storing a `Writer` into a module binding and `undefined` back over it costs a write barrier each way. That pair measured larger than the entire interpreted field loop the generated encoders had just replaced. A boolean flag carries the same re-entrancy guarantee — an encode reached from inside another one still allocates its own Writer — as an oddball store with no barrier. **`finish()`.** `slice` pays a fixed setup cost whatever the length, which is invisible on a 4 KB batch and is most of the work on an 8-byte record: 41.8 ns against 24.8 ns at 4 bytes. The crossover is 16 bytes and above it `slice` wins by more than 2×, so both paths stay. **`Writer.string`.** Below 128 code units the length varint is one byte whatever the UTF-8 length turns out to be, and for an ASCII string that length *is* the code-unit count. So the length can be written before it is known to be right, and the loop that copies the bytes is the same loop that proves it: on the first unit ≥ 0x80 the offset rewinds and the general path runs. This is also why `ASCII_ENCODE_INTO_LIMIT` no longer describes a real crossover for the lengths it covers — 16 was measured when the copy ran *after* a full scan, and one walk beats scan-plus-`encodeInto` much further out than two walks did. What is left is the per-leaf `Writer` call itself, which the generated source does not inline. That is now the largest remaining item, and it trades bundle bytes for speed like the two generators before it. ## Rejected optimizations [Section titled “Rejected optimizations”](#rejected-optimizations) Each measured as noise or worse: | Change | Result | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Presizing the `Writer` | reallocation is 0.77% of a 4.3 MB encode; Person and Event never reallocate | | `for..of` → indexed loops | 0.12 ns across three fields | | Decode results as object literals | −0.01 ns | | Lazy `DataView`, kept | a **9.7 ns regression** on float payloads | | Module-level `Float64Array` scratch | looks worth \~50%; an escape-analysis artifact that vanishes once bytes are consumed (12% through shorn’s `Reader`, 8% through a Node `Buffer`) — and host-endian, so it would byte-reverse floats on a big-endian host | | String output instead of bytes | base64 −31% throughput / +33% size; Latin-1 −25% and inflates on the wire | ## The functional API is not slower [Section titled “The functional API is not slower”](#the-functional-api-is-not-slower) The functional API reaches 3.53M encodes/s, compared with 3.49M for `compiled.encode`. The difference is measurement noise. See [Compilation and Caching](/core-concepts/compile-and-caching/). ## Methodology [Section titled “Methodology”](#methodology) Tests ran on Node v22.23.1, an Apple M4 Pro, and macOS arm64. Small-fixture results are the median of seven samples of about 180 ms after warm-up. The 100,000-event results use three single-operation samples. Every codec must round-trip to the same logical value. Schema construction is excluded and measured separately as [cold setup](/performance/footprint/). Raw tests use each codec’s normal API with SchemaPack validation disabled. Protobuf.js includes `fromObject` and `toObject` to expose the same string-enum API. **Microbenchmarks are directional.** Production decisions need representative schemas and traffic. # Rejected Shapes > Every shape shorn refuses, the error it throws, and what to do instead. shorn refuses schemas it cannot encode exactly. Unless noted, each refusal is an `EncodeError` thrown when the codec is built: during `compile()` or the first `encode()`, not on a later payload. ## Summary [Section titled “Summary”](#summary) | Shape | Refused at | Instead | | -------------------------------- | -------------------- | --------------------------------------------- | | Open object, record | build | closed object, or array of pairs | | General / discriminated union | build | enum discriminant, or dispatch by fingerprint | | Recursive schema | build | flatten, or nest as `m.bytes()` | | Input ≠ output wire shape | build | make both sides agree | | `Date`, `bigint`, `Map`, `Set` | vendor, before shorn | convert at the edge | | Transform | vendor, before shorn | `z.codec()` outside the codec | | Empty enum | build | — | | Array of zero-width element | build | encode a count instead | | A second null or presence marker | build | drop the redundant wrapper | | No Standard JSON Schema | build | pass `{ structure }` | | Async schema + codec | encode | `encodeAsync` / `decodeAsync` | | Unknown property | encode | close the object, or strip first | ## Open objects and records [Section titled “Open objects and records”](#open-objects-and-records) > Records and open objects are not currently supported Examples include `z.looseObject`, `z.record`, and `v.record`. A tagless format cannot encode a property that the schema does not name, and shorn refuses the schema instead of silently dropping data. Model the dynamic part explicitly: ```ts z.object({ attributes: z.array(z.tuple([z.string(), z.string()])) }); ``` A different rule applies when a validator omits `additionalProperties`, as ArkType and some Valibot object schemas do. The codec builds successfully, but encoding an extra property throws `Unknown object property "x"`. ## General unions [Section titled “General unions”](#general-unions) > Only nullable JSON Schema unions are currently supported A general union needs a discriminator tag in the payload. shorn deliberately avoids those tags, so general unions are unsupported by design. Use an enum field as the discriminant and make variant fields optional. Alternatively, give each variant its own codec and select it by [fingerprint](/versioning/schema-evolution/). ## Recursive schemas [Section titled “Recursive schemas”](#recursive-schemas) A `$ref` back to the root has no bounded wire shape. Without one, shorn cannot compute the `_minWidth` used to limit allocation during decoding. Flatten the schema to a fixed depth, or encode the nested part separately and store it in an `m.bytes()` field. ## Different input and output shapes [Section titled “Different input and output shapes”](#different-input-and-output-shapes) > Schemas with different input and output wire shapes require a bidirectional codec and are not yet supported shorn converts and compares both `jsonSchema.input()` and `.output()`. It refuses a default or widening refinement when the two wire shapes differ because it cannot reverse that change during encoding. With `z.codec()`, JSON Schema conversion usually throws before this check. ## Rich types [Section titled “Rich types”](#rich-types) > \ — shorn encodes the wire shape; convert rich types at the edge `z.date()`, `z.bigint()`, `z.map()`, `z.set()`, `v.date()`, ArkType `Date`. The wall is JSON Schema’s, not any vendor’s: all three throw before shorn is involved, and shorn keeps their reason and appends the remedy. See [Date, BigInt, Map, Set](/schemas/rich-types/). ## Transforms [Section titled “Transforms”](#transforms) A one-way transform has no reverse direction in Standard Schema, so shorn cannot undo it on decode. Use `z.codec()` for a declarative bidirectional pair, applied outside the codec. ## Empty enums [Section titled “Empty enums”](#empty-enums) > Empty enums are unsupported No valid value means no index to write. ## Arrays of zero-width elements [Section titled “Arrays of zero-width elements”](#arrays-of-zero-width-elements) ```ts z.array(z.literal("x")); // literal encodes to 0 bytes z.array(z.tuple([])); z.array(z.object({})); ``` An array element must be able to use at least one byte. Otherwise, a tiny payload could declare a million elements without providing any element data, and the decoder could not bound the allocation. A **tuple** may contain zero-width elements because its length comes from the schema. See [Hostile Input](/hostile-input/). If you need a count of a constant, encode the count: `z.int().nonnegative()`. ## Stacked null or presence markers [Section titled “Stacked null or presence markers”](#stacked-null-or-presence-markers) > This schema already decodes to null; wrapping it in nullable() would give null two encodings > This schema already decodes to undefined; wrapping it in optional() would give undefined two encodings ```ts m.literal(null).nullable(); // null is already the only value m.string().optional().nullable().optional(); // undefined would have two spellings compile(z.string().nullable()).nullable(); // the flag survives compile() ``` Two markers for the same value would make `[0]` and `[1, 0]` decode alike, so distinct payloads would produce the same value and decoding would no longer be injective. Repeating one wrapper — `x.optional().optional()` — is not an error: it collapses and returns the identical object, because `T | undefined | undefined` is exactly `T | undefined`. Only a genuinely duplicated marker throws. Drop the redundant wrapper. Mixing the two once is supported and meaningful: `m.string().optional().nullable()` tells absent apart from null. ## Missing structural interface [Section titled “Missing structural interface”](#missing-structural-interface) > Standard Schema provides validation but not structure; pass a Standard JSON Schema implementation as the second argument Valibot always needs this option, as do Zod versions before 4.2 and ArkType versions before 2.1.28. Pass `{ structure }`; see [Valibot](/validators/valibot/). ## Async validation with a codec [Section titled “Async validation with a codec”](#async-validation-with-a-codec) > This Standard Schema validates asynchronously; use encodeAsync/decodeAsync with the Standard Schema. Neither accepts a compiled or fingerprinted codec. Async validation does not compose with `fingerprinted()`. See [Validation](/core-concepts/validation/). # Date, BigInt, Map, Set > JSON Schema has no form for these, so every vendor refuses them before shorn sees anything. Convert at the edge. | Schema | Result | | -------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `z.date()`, `z.bigint()`, `z.map()`, `z.set()`, `z.undefined()`, `z.nan()` | Zod: *“X cannot be represented in JSON Schema”* | | `v.date()` | Valibot: *“The ‘date’ schema cannot be converted to JSON Schema”* | | ArkType `Date`, `bigint` | `{ code: "date" }`, `{ code: "domain", domain: "bigint" }` | | `z.string().transform(...)` | Zod: *“Transforms cannot be represented”* | **This is a JSON Schema limitation, not a validator-specific one.** shorn gets structure through Standard JSON Schema, so it cannot encode values that JSON Schema cannot describe. shorn preserves the validator’s error and adds guidance for converting the value. ## The pattern [Section titled “The pattern”](#the-pattern) **shorn encodes a wire-friendly shape. Convert rich values at the application boundary.** In Zod, `z.codec()` can define both conversions: ```ts const Rich = z.object({ when: z.codec(z.iso.datetime(), z.date(), { decode: (text) => new Date(text), encode: (date) => date.toISOString(), }), id: z.codec(z.string(), z.bigint(), { decode: (text) => BigInt(text), encode: (big) => big.toString(), }), }); const Wire = z.object({ when: z.iso.datetime(), id: z.string() }); const codec = fingerprinted(compile(Wire)); const bytes = codec.encode(z.encode(Rich, value)); // rich → wire → bytes const back = z.decode(Rich, codec.decode(bytes)); // bytes → wire → rich ``` This pattern is tested end to end for `Date` and `9007199254740993n`, a bigint above `Number.MAX_SAFE_INTEGER` that a numeric encoding would corrupt. Valibot and ArkType transforms do not expose a reverse direction through Standard Schema, so write both conversions explicitly. Use a `Wire` schema for shorn and convert outside it. ## Why shorn cannot collapse the two calls [Section titled “Why shorn cannot collapse the two calls”](#why-shorn-cannot-collapse-the-two-calls) * **Standard Schema v1 exposes only `validate` and `jsonSchema`.** It has no reverse operation. `z.encode` is specific to Zod, and calling it would require validator-specific code. * **For a Zod codec, `jsonSchema.output()` throws.** `input()` returns the wire shape, while the output is the rich type. shorn needs both sides to agree. * **shorn calls the schema’s validation direction during both encode and decode.** Supplying `{ structure }` does not make a bidirectional codec work: rich values fail validation as wire values, while wire values are transformed into rich values that the wire codec cannot encode. ## Choosing a wire form [Section titled “Choosing a wire form”](#choosing-a-wire-form) | Rich value | Wire form | Cost | | ---------------------------- | -------------------------- | ---------------- | | `Date` | `z.iso.datetime()` string | \~25 bytes | | `Date`, if you own both ends | `z.int()` epoch millis | 6–7 bytes | | `bigint` | `z.string()` | digits + 1 | | `Map` | `z.array(z.tuple([K, V]))` | count + entries | | `Map` with known keys | `z.object({...})` | bitmap + values | | `Set` | `z.array(T)` | count + elements | | `undefined` field | `z.optional(T)` | one bit | An epoch integer is about 20 bytes smaller than an ISO-8601 string. You can choose it in your own codec, but shorn will not convert automatically because a round trip could change the original string representation. shorn also avoids SuperJSON-style type tags because adding a tag to each rich value would make payloads larger. ## Why conversion stays outside shorn [Section titled “Why conversion stays outside shorn”](#why-conversion-stays-outside-shorn) The [fingerprint](/versioning/fingerprinting/) identifies the wire shape. Changing only the conversion functions does not change the bytes, so it does not change the fingerprint. ## Values with no sensible wire form [Section titled “Values with no sensible wire form”](#values-with-no-sensible-wire-form) Values such as `RegExp`, `URL`, class instances, and functions need an explicit wire representation. Store only the data you need, such as a URL string or a regular expression’s source and flags, and convert at the application boundary. [ADR 0003](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0003-rich-types-are-the-validators-job.md) reopens the day Standard Schema gains a reverse direction, which would collapse the two calls into one with no wire change. # Supported Types > Every schema shape shorn can encode, with the vendor spelling and byte cost for each. shorn supports the intersection of two sets: shapes JSON Schema can describe and shapes a tagless format can encode. See [Rejected Shapes](/schemas/rejected-shapes/) for unsupported cases. ## Primitives [Section titled “Primitives”](#primitives) | Shape | Zod | Valibot | ArkType | Bytes | | ------------ | ----------------------- | --------------------------------- | ----------------------- | --------------------- | | String | `z.string()` | `v.string()` | `"string"` | varint length + UTF-8 | | Boolean | `z.boolean()` | `v.boolean()` | `"boolean"` | 1 | | Signed int | `z.int()` | `v.pipe(v.number(), v.integer())` | `"number.integer"` | ZigZag varint | | Unsigned int | `z.int().nonnegative()` | `+ v.minValue(0)` | `"number.integer >= 0"` | varint | | Float | `z.number()` | `v.number()` | `"number"` | 8 | | Literal | `z.literal(v)` | `v.literal(v)` | `"'M'"` | 0 | | Enum | `z.enum([...])` | `v.picklist([...])` | `"'M' \| 'F'"` | varint index | Declare non-negative integers when possible. ZigZag encoding doubles the encoded magnitude, so a signed `int` needs an extra byte at lower values than a `uint`. ## Collections [Section titled “Collections”](#collections) | Shape | Zod | Valibot | ArkType | Bytes | | ----- | ---------------- | ---------------- | ---------------------- | ----------------------- | | Array | `z.array(T)` | `v.array(T)` | `"T[]"` | varint count + elements | | Tuple | `z.tuple([...])` | `v.tuple([...])` | `["string", "number"]` | elements only | An array’s count is on the wire; a tuple’s comes from the schema. That is why a tuple may contain zero-width elements and an array may not. ## Objects [Section titled “Objects”](#objects) | Shape | Zod | Valibot | ArkType | | -------------- | ----------------------- | ----------------------- | ------------------ | | Closed | `z.object({...})` | `v.object({...})` | `type({...})` | | Strict | `z.strictObject({...})` | `v.strictObject({...})` | `"+": "reject"` | | Optional field | `z.optional(T)` | `v.optional(T)` | `"key?": "string"` | Objects write a presence bitmap for optional fields (`ceil(n / 8)` bytes, omitted when there are no optional fields), followed by values in canonical key order. Field names are never written. The validator and shorn may handle extra properties differently; see [Zod](/validators/zod/), [Valibot](/validators/valibot/), and [ArkType](/validators/arktype/). ## Nullable [Section titled “Nullable”](#nullable) `z.nullable(T)` · `v.nullable(T)` · `"T | null"` → one discriminator byte + value. Both JSON Schema spellings work: an `anyOf` of two branches where one is `null`, and a `type` array of two entries where one is `"null"`. Nullable is the **only** union supported. ## Nesting [Section titled “Nesting”](#nesting) Objects, arrays, and tuples can be nested without adding a per-level header. A nested object is encoded as only its fields. In the benchmark, the nested `Event` uses 43 bytes, compared with 163 for JSON and 44 for Avro. Recursive schemas are unsupported because a `$ref` to the root has no bounded wire shape, so shorn cannot compute `_minWidth`. There is also no depth limit for non-recursive nesting. At about 5,900 levels, JavaScript throws a `RangeError` instead of a `DecodeError`. This requires a hostile *schema*, not merely hostile bytes. ## Refinements are validated, not encoded [Section titled “Refinements are validated, not encoded”](#refinements-are-validated-not-encoded) `.min()`, `.max()`, `.regex()`, `.email()`, and `.refine()` run during encode and decode but do not change the wire format. Adding `.max(300)`, for example, does not change the [fingerprint](/versioning/fingerprinting/). One exception: `minimum >= 0` on an integer selects the unsigned varint, so it *does* change bytes and fingerprint. ## Low-level extras [Section titled “Low-level extras”](#low-level-extras) No JSON Schema form, so no validator selects them — reachable only via the [`m` API](/wire-format/low-level-api/): | Shape | Builder | Bytes | | ------------ | ------------- | ------------------------ | | Raw bytes | `m.bytes()` | varint length + contents | | 32-bit float | `m.float32()` | 4 | # ArkType > ArkType 2.1.28+ implements both Standard interfaces directly. Its open objects are the one thing to know about. ArkType 2.1.28 and newer implements both Standard Schema and Standard JSON Schema directly. Pass the type and nothing else. ```ts import { type } from "arktype"; import { decode, encode } from "shorn"; const Person = type({ name: "string", age: "number.integer >= 0", sex: "'M' | 'F' | 'X'", }); const bytes = encode(Person, person); // 8 bytes ``` An equivalent Zod schema produces the same bytes and fingerprint. ## Wire mapping [Section titled “Wire mapping”](#wire-mapping) | ArkType | Wire | | ----------------------- | ----------------------------------------------- | | `"string"` | varint length + UTF-8 | | `"boolean"` | one byte | | `"number.integer"` | ZigZag varint | | `"number.integer >= 0"` | plain varint | | `"number"` | float64, always 8 bytes | | `"'M' \| 'F' \| 'X'"` | varint index in sorted order | | `"'M'"` | zero bytes | | `"string[]"` | varint count + elements | | `["string", "number"]` | tuple: elements only | | `{ ... }` | presence bitmap + values in canonical key order | | `"key?": "string"` | a bit in the presence bitmap | | `"string \| null"` | discriminator byte + value | Use `"number.integer >= 0"` when the value cannot be negative. Signed integers use ZigZag encoding and need an extra byte at lower values. ## Extra properties [Section titled “Extra properties”](#extra-properties) ArkType objects are open by default. Unknown properties pass through validation, and ArkType does not emit `additionalProperties: false`. shorn therefore checks for extras during encoding and rejects them instead of dropping data. The codec still builds successfully. | Type | `{ name: "Grace", extra: true }` | | ----------------------------------------- | ---------------------------------------------- | | `type({ name: "string" })` | shorn throws `Unknown object property "extra"` | | `type({ name: "string", "+": "delete" })` | encodes; ArkType strips `extra` | | `type({ name: "string", "+": "reject" })` | ArkType throws `extra must be removed` | Use `"+": "delete"` to strip expected extras, or `"+": "reject"` to report ArkType’s validation error. Leaving the object open also works because shorn will reject extras during encoding. The [fingerprint](/versioning/fingerprinting/) excludes `rejectUnknown`, so equivalent ArkType and Zod schemas still agree even though they handle extra properties differently. ## Rich types [Section titled “Rich types”](#rich-types) `Date` and `bigint` fail during JSON Schema conversion, before shorn receives them. ArkType reports `{ code: "date" }` and `{ code: "domain", domain: "bigint" }`. Convert rich values at the application boundary and encode a wire-friendly shape; see [Date, BigInt, Map, Set](/schemas/rich-types/). Standard Schema has no reverse operation, so shorn cannot run an ArkType morph backwards. A morph is also refused when its input and output produce different wire shapes. shorn requires both sides to agree on the encoded bytes. ## Version note [Section titled “Version note”](#version-note) 2.1.28 is the floor. Earlier versions lack Standard JSON Schema, so `encode` throws *“provides validation but not structure”* — pass `{ structure }`. # Valibot > Valibot keeps JSON Schema conversion in a separate package, so shorn takes the converted structure as an option. Valibot implements Standard Schema but provides JSON Schema conversion in a separate, tree-shakeable package. Pass the output of its official converter to shorn. ```ts import * as v from "valibot"; import { toStandardJsonSchema } from "@valibot/to-json-schema"; import { decode, encode } from "shorn"; const Person = v.object({ name: v.string(), age: v.pipe(v.number(), v.integer(), v.minValue(0)), sex: v.picklist(["M", "F", "X"]), }); const structure = toStandardJsonSchema(Person); const bytes = encode(Person, person, { structure }); // 8 bytes const decoded = decode(Person, bytes, { structure }); ``` That extra `structure` option is the only difference from Zod and ArkType. All three produce the same eight bytes and fingerprint. ## Convert once [Section titled “Convert once”](#convert-once) The plan is cached by the identity of **both** the schema and structure objects. Creating a new structure on every call therefore rebuilds the plan, and `toStandardJsonSchema` also has its own cost. ```ts // Cached. const PersonWire = compile(Person, { structure: toStandardJsonSchema(Person) }); // Not cached: a new structure object per call. encode(Person, person, { structure: toStandardJsonSchema(Person) }); ``` Hoist the structure to a module constant, or keep the `compile` codec. ## Wire mapping [Section titled “Wire mapping”](#wire-mapping) | Valibot | Wire | | --------------------------------- | ----------------------------------------------- | | `v.string()` | varint length + UTF-8 | | `v.boolean()` | one byte | | `v.pipe(v.number(), v.integer())` | ZigZag varint | | `+ v.minValue(0)` | plain varint | | `v.number()` | float64, always 8 bytes | | `v.picklist([...])` | varint index in sorted order | | `v.literal(...)` | zero bytes | | `v.array(T)` | varint count + elements | | `v.tuple([...])` | elements only | | `v.object({...})` | presence bitmap + values in canonical key order | | `v.optional(T)` | a bit in the presence bitmap | | `v.nullable(T)` | discriminator byte + value | Add `v.minValue(0)` when the value cannot be negative. Signed integers use ZigZag encoding and need an extra byte at lower values. ## Extra properties [Section titled “Extra properties”](#extra-properties) All three object variants compile. Only `v.record` is refused outright. | Schema | `{ a: "x", b: 1 }` | | ---------------- | ------------------------------------------------------------- | | `v.object` | encodes; Valibot strips `b` | | `v.strictObject` | Valibot throws `Invalid key: Expected never but received "b"` | | `v.looseObject` | shorn throws `Unknown object property "b"` | | `v.record` | refused when the codec is built | Only `v.strictObject` emits `additionalProperties: false`. The converter omits that setting for `v.object` and `v.looseObject`, so shorn checks extras itself. That is why `looseObject` produces shorn’s error instead of passing the property through. In contrast, Zod’s `z.looseObject` emits `additionalProperties: true`, so shorn refuses it when the codec is built. The similar API names produce different results because their JSON Schema converters emit different structures. The [fingerprint](/versioning/fingerprinting/) excludes `rejectUnknown`. As a result, `v.object`, `v.strictObject`, and equivalent Zod schemas share the same bytes and fingerprint. ## Rich types [Section titled “Rich types”](#rich-types) `v.date()` throws *“The ‘date’ schema cannot be converted to JSON Schema”* before shorn receives it. `v.pipe` transforms also have no reverse operation through Standard Schema. Convert rich values at the application boundary; see [Date, BigInt, Map, Set](/schemas/rich-types/). # Zod > Zod 4.2+ implements both Standard interfaces directly, so shorn needs no adapter and no second argument. Zod 4.2 and newer implements both Standard Schema and Standard JSON Schema directly. Pass the schema and nothing else. ```ts import { z } from "zod"; import { compile, decode, encode, fingerprinted } from "shorn"; const Person = z.object({ name: z.string(), age: z.int().nonnegative(), sex: z.enum(["M", "F", "X"]), }); const bytes = encode(Person, person); // 8 bytes const decoded = decode(Person, bytes); const PersonWire = compile(Person); // reusable codec const PersonStored = fingerprinted(compile(Person)); // + 3 bytes of identity ``` ## Wire mapping [Section titled “Wire mapping”](#wire-mapping) | Zod | Wire | | ----------------------------- | ----------------------------------------------- | | `z.string()` | varint length + UTF-8 | | `z.boolean()` | one byte | | `z.int()` | ZigZag varint | | `z.int().nonnegative()` | plain varint | | `z.number()` | float64, always 8 bytes | | `z.enum([...])` | varint index in sorted order | | `z.literal(...)` | zero bytes | | `z.array(T)` | varint count + elements | | `z.tuple([...])` | elements only | | `z.object` / `z.strictObject` | presence bitmap + values in canonical key order | | `z.optional()` | a bit in the presence bitmap | | `z.nullable()` | discriminator byte + value | Use `z.int().nonnegative()` when the value cannot be negative. shorn then uses an unsigned varint. For example, `127` takes one byte as a `uint` and two bytes as a ZigZag-encoded `int`. ## Extra properties [Section titled “Extra properties”](#extra-properties) | Schema | `{ name: "Grace", extra: true }` | | ---------------- | -------------------------------------- | | `z.object` | encodes; Zod strips `extra` first | | `z.strictObject` | Zod throws `Unrecognized key: "extra"` | | `z.looseObject` | refused when the codec is built | `z.object` and `z.strictObject` produce the same bytes and fingerprint because both emit `additionalProperties: false`. `z.looseObject` and `z.record` emit `additionalProperties: true`. Because shorn cannot encode unnamed properties, these schemas fail during codec construction. ## Refinements are validated, not encoded [Section titled “Refinements are validated, not encoded”](#refinements-are-validated-not-encoded) `.min`, `.max`, `.regex`, `.refine` run on encode and decode and never change a byte. Adding `.max(300)` does not reissue the [fingerprint](/versioning/fingerprinting/). ## Rich types: `z.codec()` [Section titled “Rich types: z.codec()”](#rich-types-zcodec) `z.date()` and `z.bigint()` fail during JSON Schema conversion because JSON Schema cannot represent them. Convert at the application boundary: ```ts const Rich = z.object({ when: z.codec(z.iso.datetime(), z.date(), { decode: (text) => new Date(text), encode: (date) => date.toISOString(), }), }); const Wire = z.object({ when: z.iso.datetime() }); const codec = fingerprinted(compile(Wire)); const bytes = codec.encode(z.encode(Rich, value)); const back = z.decode(Rich, codec.decode(bytes)); ``` This requires two calls because Standard Schema has no reverse operation. shorn cannot call Zod’s `z.encode` without adding validator-specific code. See [Date, BigInt, Map, Set](/schemas/rich-types/). ## Async refinements [Section titled “Async refinements”](#async-refinements) ```ts const bytes = await encodeAsync(Person, person); const back = await decodeAsync(Person, bytes); ``` Both functions accept the schema rather than a codec, so async validation does not compose with `fingerprinted()`. See [Validation](/core-concepts/validation/). ## Version note [Section titled “Version note”](#version-note) Zod 4.2 is the floor. Earlier Zod 4 releases lack Standard JSON Schema, so `encode` throws *“provides validation but not structure”* — pass `{ structure }` from `z.toJSONSchema`. # Fingerprinting > Bare payloads decode to a wrong value 27% of the time when the schema changes. fingerprinted() costs 3 bytes and turns that into a DecodeError. Read this before you store anything Positional decoding has no built-in way to identify the schema. In a test of 16,861 schemas that differed by one edit, the decoder rejected 58.1%. Another 15.2% happened to produce the same value, while **26.7% produced the wrong value without an error.** Three of the six tested schema edits can silently corrupt data: * **Renaming two fields** can swap their values because field order is derived from each name. * Widening `uint` to `int` **halves the number**, because ZigZag doubles the magnitude. * **Adding an enum member** shifts the index of each member at or after it. None of these changes necessarily throws. The decoder can return a plausible object with incorrect data. ## The fix [Section titled “The fix”](#the-fix) ```ts const PersonWire = fingerprinted(compile(Person)); const bytes = PersonWire.encode(person); // 11 bytes: 3 fingerprint + 8 payload PersonWire.decode(bytes); // throws if the schema changed ``` > DecodeError: Payload was written by a different schema (expected fingerprint 7236d1) **Use it for anything stored, queued, or crossing a version boundary.** Bare payloads are for endpoints pinned to one schema at deploy time. ## Why it is not the default [Section titled “Why it is not the default”](#why-it-is-not-the-default) The reason is payload size. The fingerprint increases Person from 8 to 11 bytes, making it larger than Avro at 8 bytes and SchemaPack at 9. The compact default requires care: if you remember only one safety rule, fingerprint stored or version-crossing payloads. ## Cost [Section titled “Cost”](#cost) | | | | ------ | ----------------------------- | | Wire | 3 bytes (default) | | Encode | \~1.5 ns | | Decode | \~6.3 ns | | Bundle | +379 gzip, only for importers | The decoder reads fingerprint bytes in a loop. It does not call `reader.bytes(n)`, which returns a new subarray and costs about 24 ns in the benchmark. ## What changes the fingerprint [Section titled “What changes the fingerprint”](#what-changes-the-fingerprint) It hashes the canonical **wire structure**, so only byte-moving changes reissue it. **Reissues:** adding, removing, or renaming a field · changing a type · required ↔ optional · adding or removing an enum member · `z.int()` ↔ `z.int().nonnegative()` · reordering a tuple. **Does not reissue:** reordering property declarations · adding any refinement · `z.object` ↔ `z.strictObject` · switching vendors · changing a `z.codec()`’s conversion functions. The last two cases are intentional. The signature excludes `rejectUnknown`, so equivalent schemas agree even when validators handle extra properties differently. Conversion functions also live outside the wire shape, so changing them does not invalidate existing payloads. ## Width [Section titled “Width”](#width) | Bytes | Odds of a silent wrong-value decode | | ----: | ----------------------------------- | | 1 | \~1 in 958 | | 2 | \~1 in 245,000 | | 3 | \~1 in 63,000,000 | | 4 | better still | ```ts fingerprinted(compile(Person), { bytes: 2 }); ``` **Performance is effectively the same for all four widths.** Use the default 3 bytes for stored data. One- or two-byte fingerprints are only appropriate for tightly controlled protocols that accept the higher collision risk. The format version and fingerprint width are mixed into the **low** byte of the FNV-1a seed. This is required because a seed bit at position 8 or above cannot affect the low output byte. Mixing them higher would make a one-byte fingerprint ignore both values. ## Carrying the fingerprint separately [Section titled “Carrying the fingerprint separately”](#carrying-the-fingerprint-separately) ```ts codec.fingerprint; // Uint8Array, a fresh copy every read codec.fingerprintHex; // "7236d1", stable and safe to reuse ``` Put those in a Kafka header, a column, or a filename and keep the payload bare. This is also the workaround for the async gap. `fingerprint` returns a copy so callers cannot mutate the codec’s internal bytes. `fingerprintHex` is immutable and can be used as a `Map` key. ## It is not an authentication tag [Section titled “It is not an authentication tag”](#it-is-not-an-authentication-tag) The digest is **unkeyed**: anyone with the schema can compute or forge it. It detects schema mismatches but is not a message authentication code. Sign or encrypt payloads when you need authenticity. ## The format version rides along [Section titled “The format version rides along”](#the-format-version-rides-along) `WIRE_FORMAT_VERSION` is included in the fingerprint seed instead of stored as a separate byte. A decoder using a different wire-format version therefore derives a different fingerprint and rejects the payload. See [Schema Evolution](/versioning/schema-evolution/). ## Limitations [Section titled “Limitations”](#limitations) * **Requires a `compile()` codec.** `fingerprinted(m.object({...}))` throws — raw wire is unframed by design. * **Does not compose with async validation.** Carry it out of band instead. * **Detects, never resolves.** Reading the payload anyway is [Schema Evolution](/versioning/schema-evolution/). # Schema Evolution > There isn't any. shorn detects a mismatch and never resolves one. Keep the codec that wrote the bytes and dispatch on its fingerprint. **shorn has no schema evolution.** A payload is decodable only by the schema that wrote it, and the [fingerprint](/versioning/fingerprinting/) reports a mismatch rather than resolving one. This is a deliberate format decision. Field tags would enable evolution but make every record larger. The Person fixture is 8 bytes in shorn and Avro, 9 in SchemaPack, and 10 in msgpackr records. ## The policy: dispatch, don’t resolve [Section titled “The policy: dispatch, don’t resolve”](#the-policy-dispatch-dont-resolve) ```ts const v1 = fingerprinted(compile(PersonV1)); const v2 = fingerprinted(compile(PersonV2)); const byVersion = new Map([ [v1.fingerprintHex, v1], [v2.fingerprintHex, v2], ]); function read(payload: Uint8Array) { const key = [...payload.subarray(0, 3)] .map((b) => b.toString(16).padStart(2, "0")) .join(""); const codec = byVersion.get(key); if (!codec) throw new Error(`No codec for schema ${key}`); return codec.decode(payload); } ``` Use `fingerprintHex` as the map key. `fingerprint` returns a new array on each access, so it cannot be used directly as a stable `Map` key. Write with the newest codec and read with the codec that matches each payload. Remove an old codec only after no payloads use its fingerprint. Migrate old payloads in a batch instead of converting them on every read. shorn does not include a schema registry; the dispatch table is an ordinary `Map`. A generic `decodeAny` would return an impractically broad union type. ## Why no field tags [Section titled “Why no field tags”](#why-no-field-tags) **On-wire tags make tiny records larger.** Protobuf uses them to support evolution, but that tradeoff conflicts with shorn’s focus on compact payloads. **Position-based IDs do not solve optional fields.** The presence bitmap uses `ceil(n / 8)` bytes, and each field’s bit depends on its rank among optional fields. Adding a ninth optional field grows the bitmap and causes old payloads to be misread. Prefixing the bitmap with its length would add overhead and still would not support a new *required* field. shorn therefore makes no partial compatibility promise. **Avro already provides mature schema evolution and ties shorn on size** by keeping the writer’s schema out of band. Use Avro when you need automatic resolution. See [ADR 0002](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0002-no-schema-evolution-only-mismatch-detection.md). ## In practice [Section titled “In practice”](#in-practice) * **Persistent and queued payloads MUST be fingerprinted.** A bare stored payload has no recoverable identity once the schema moves. * **Applications retain every historical codec** used to write data. This requires explicit version bookkeeping. * **Adding a new wire type does not break existing fingerprints**, because it does not alter the signature of any existing shape. Safe in a minor release. ## The format itself is frozen [Section titled “The format itself is frozen”](#the-format-itself-is-frozen) The wire format is frozen for existing shapes. A new wire type may be added, but it cannot change payloads that do not use it. **Changing the encoding of an existing shape would invalidate stored payloads, so shorn will not do that in a compatible release.** If this guarantee ever changes, it will happen in a major release with migration instructions: pin the old package, re-encode stored data, and then upgrade. The payload does not contain a separate format-version byte. Such a byte could identify the required decoder but could not supply it; only the old package can read the old format. The fingerprint instead turns a silent misread into an explicit error. ## Migrating a schema [Section titled “Migrating a schema”](#migrating-a-schema) 1. Keep the old schema object, renamed. Do not edit it. 2. Add the new one. 3. Register both fingerprints. 4. Write with the new codec; read with whichever matches. 5. Optionally batch-re-encode and drop the old entry. ```ts function migrate(payload: Uint8Array) { const old = v1.decode(payload); // throws if it was not v1 const [firstName, ...rest] = old.name.split(" "); return v2.encode({ ...old, firstName, lastName: rest.join(" ") }); } ``` Without the fingerprint, line one would have returned a plausible object with the values in the wrong fields. ## What would reopen this [Section titled “What would reopen this”](#what-would-reopen-this) * A wire-format change making the presence bitmap length-prefixed for other reasons. * Evidence of applications hand-maintaining large dispatch tables, which is the signal a registry earns its keep. * Cross-language consumers, which would make out-of-band schema distribution unavoidable. Currently a non-goal. # Byte Layout > Every wire type, byte by byte, with the rules that make the encoding canonical. The format is **tagless and positional**. Payloads contain no field names, type markers, separators, or version bytes; the schema supplies their meaning. Every example below comes from the published implementation. ## Integers [Section titled “Integers”](#integers) Unsigned: base-128 varints, little-endian groups, high bit as continuation flag. | Value | Bytes | | ----- | ---------- | | `0` | `[0]` | | `127` | `[127]` | | `128` | `[128, 1]` | Signed: **ZigZag** first, mapping `0, -1, 1, -2, 2` to `0, 1, 2, 3, 4`, then the same varint. | Value | Bytes | | ----- | ---------- | | `-1` | `[1]` | | `63` | `[126]` | | `64` | `[128, 1]` | ZigZag doubles the magnitude, so a signed integer crosses every size boundary at half the value: `64` is two bytes as an `int`, one as a `uint`. Declare `minimum >= 0` wherever it is true. **Overlong varints are rejected.** `1` must be `[1]`, never `[129, 0]`. ## Floats [Section titled “Floats”](#floats) `z.number()` is little-endian IEEE-754 **float64**, always 8 bytes, no varint compaction. ```plaintext 1.5 -> [0, 0, 0, 0, 0, 0, 248, 63] ``` `m.float32()` (4 bytes) is available only through the low-level API. Little-endian order is part of the format and does not depend on the host. An optimization using `Float64Array` was rejected because it would reverse the bytes on a big-endian host. ## Booleans [Section titled “Booleans”](#booleans) One byte, `[1]` or `[0]`. Anything else is a `DecodeError`. ## Strings and bytes [Section titled “Strings and bytes”](#strings-and-bytes) A varint **byte** length, then the contents. Strings are UTF-8; `m.bytes()` is raw. ```plaintext "ab" -> [2, 97, 98] Uint8Array([9, 9]) -> [2, 9, 9] ``` UTF-8 decoding is strict. Invalid sequences cause a `DecodeError` instead of being replaced with `U+FFFD`. ## Literals [Section titled “Literals”](#literals) Zero bytes — the schema already knows the value. ```plaintext m.literal("x") with "x" -> [] ``` ## String enums [Section titled “String enums”](#string-enums) The index of the value in **sorted** order, as a varint. Members are deduplicated and sorted first, so declaration order is irrelevant. ```plaintext m.enum(["M", "F", "X"]) // sorted to ["F", "M", "X"] "X" -> [2] ``` An index past the last member is a `DecodeError`. **Adding a member shifts every index at or after it** — see [Fingerprinting](/versioning/fingerprinting/). ## Nullable [Section titled “Nullable”](#nullable) One discriminator byte, then the value if present. ```plaintext null -> [0] 5 -> [1, 5] ``` ## Arrays [Section titled “Arrays”](#arrays) A varint element count, then elements back to back. Order is never changed. ```plaintext [1, 2, 3] -> [3, 1, 2, 3] ``` The decoder refuses a count larger than the remaining input could satisfy, before allocating — see [Hostile Input](/hostile-input/). ## Tuples [Section titled “Tuples”](#tuples) Elements only; the length comes from the schema. ```plaintext m.tuple([m.uint(), m.boolean()]) with [7, true] -> [7, 1] ``` Because the length is not on the wire, a tuple *may* contain zero-width elements where an array may not. ## Objects [Section titled “Objects”](#objects) 1. A **presence bitmap** for the optional fields, `ceil(n / 8)` bytes. Omitted entirely when there are none. 2. The field values in canonical key order, skipping absent optionals. A field’s bit is its rank **among the optional fields**, low bit first. ```ts m.object({ a: m.uint().optional(), b: m.uint() }) { a: 1, b: 2 } -> [1, 1, 2] // bitmap 1, then a, then b { b: 2 } -> [0, 2] // bitmap 0, a skipped ``` Nine optional fields make the bitmap two bytes: ```plaintext 9 optional, all absent -> [0, 0] 9 optional, all present, each 1 -> [255, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] ``` Field order is the field name’s rank in ascending UTF-16 code-unit order, applied by the encoder, never declared. **The bitmap width is fixed by the schema** — which is why append-only compatibility fails: a ninth optional field adds a byte and every earlier payload misreads. See [Schema Evolution](/versioning/schema-evolution/). ## A whole record [Section titled “A whole record”](#a-whole-record) ```ts encode(Person, { name: "Grace", age: 45, sex: "F" }); ``` ```plaintext [45, 5, 71, 114, 97, 99, 101, 0] │ │ └────────────────────┘ └─ sex: index of "F" in ["F","M","X"] │ └─ name: length 5 └─ age: uint varint 45 (no bitmap: nothing is optional) ``` Eight bytes. `age` comes first because `"age"` sorts before `"name"`. JSON spends 35. ## Decoder limits [Section titled “Decoder limits”](#decoder-limits) | Limit | Value | | -------------------------- | --------- | | Collection elements | 1,000,000 | | String / byte-array length | 64 MiB | | Trailing bytes | rejected | | Non-canonical varint | rejected | | Unsafe numeric varint | rejected | ## What is not in the payload [Section titled “What is not in the payload”](#what-is-not-in-the-payload) No schema identifier, version byte, length prefix on the whole value, or type tags. The format version is hashed into the [fingerprint](/versioning/fingerprinting/) instead of spent as a wire byte. Bare payloads are compact, but they are neither self-describing nor confidential. **Encrypt them when secrecy is required.** # Low-Level m API > The m builders write the wire format directly, with no validator. An escape hatch, not a replacement for your schema library. `m` builds codecs directly from the wire format, without a validation library or JSON Schema. Use it when you need direct control over the wire shape. ```ts import { m } from "shorn"; const Person = m.object({ name: m.string(), age: m.uint(), sex: m.enum(["M", "F", "X"]), }); const bytes = Person.encode({ name: "Grace", age: 45, sex: "F" }); // [45, 5, 71, 114, 97, 99, 101, 0] — identical to the compiled Zod codec ``` Types are inferred: `Person.decode(bytes)` is `{ name: string; age: number; sex: "M" | "F" | "X" }`. ## It does not replace your validator [Section titled “It does not replace your validator”](#it-does-not-replace-your-validator) `m` performs **only the validation needed to write the wire format**. For example, an integer must be safe and a string must actually be a string. It does not enforce `.min(1)`, email formats, or business rules, and it has no `.refine()`. Use it when no validator schema is available, when you need `float32` or raw `bytes`, or when writing a test fixture. ## It costs all twelve builders or none [Section titled “It costs all twelve builders or none”](#it-costs-all-twelve-builders-or-none) `m` is one object, so a bundler cannot drop the builders you never call: `import { m }` is about 3.9 KB gzip whether you use two or twelve. Everything *else* in shorn tree-shakes per export — see [Footprint](/performance/footprint/). If a bundle is tight and only needs the compiled path, importing `codec` without `m` is 137 gzip bytes cheaper. The reasoning, and the named-export alternative that was measured and declined, are in [ADR 0004](https://github.com/ChiChuRita/shorn/blob/main/docs/adr/0004-m-stays-one-object.md). ## Builders [Section titled “Builders”](#builders) | Builder | Wire | Notes | | ----------------- | ------------------------ | ------------------------------------- | | `m.string()` | varint length + UTF-8 | fatal UTF-8 decoding | | `m.bytes()` | varint length + raw | no JSON Schema equivalent | | `m.boolean()` | one byte | | | `m.uint()` | varint | non-negative safe integers | | `m.int()` | ZigZag varint | | | `m.float32()` | 4 bytes LE | no JSON Schema equivalent | | `m.float64()` | 8 bytes LE | what `z.number()` selects | | `m.literal(v)` | zero bytes | `string \| number \| boolean \| null` | | `m.enum([...])` | varint index, sorted | needs ≥1 member | | `m.array(item)` | varint count + elements | | | `m.tuple([...])` | elements only | length from the schema | | `m.object({...})` | presence bitmap + values | canonical key order | Every `Schema` also has `.optional()` and `.nullable()`: ```ts m.object({ name: m.string(), nickname: m.string().optional(), // a bit in the presence bitmap manager: m.string().nullable(), // a discriminator byte }); ``` ## What it deliberately cannot do [Section titled “What it deliberately cannot do”](#what-it-deliberately-cannot-do) `m` **cannot override canonical field order or the enum index base** because both are wire-format rules. `m.object({ b, a })` writes `a` first, just like a compiled codec. `test/golden.test.ts` verifies this byte compatibility and cross-validator identity. ## `fingerprinted()` refuses an `m` codec [Section titled “fingerprinted() refuses an m codec”](#fingerprinted-refuses-an-m-codec) ```ts fingerprinted(m.object({ name: m.string() })); // EncodeError: fingerprinted() needs a codec built from a Standard JSON Schema; // compile() returns one, the low-level m API does not ``` The fingerprint hashes the canonical structural signature, derived only from a Standard JSON Schema. Raw wire is unframed by design. ## Custom schemas [Section titled “Custom schemas”](#custom-schemas) ```ts import { Reader, Schema, Writer } from "shorn"; class Pair extends Schema<[number, number]> { _minWidth = 2; _encode(writer: Writer, value: [number, number]) { writer.varuint(value[0]); writer.varuint(value[1]); } _decode(reader: Reader): [number, number] { return [reader.varuint(), reader.varuint()]; } } ``` Set `_minWidth`, the fewest bytes a value can use, if the schema may appear in an array. This lets `m.array()` reject an impossible count before allocating. A schema with width 0 cannot be an array element. On performance-critical paths, prefer a `byte()` loop to `reader.bytes(n)`. The latter returns a new subarray and costs about 24 ns in the benchmark. This surface is unstable — `_encode`, `_decode`, and `_minWidth` can change in a minor release. See [m Builders](/api/m/) for full signatures.