shorn

Your validator is the wire format.

Already have a Zod, Valibot, or ArkType schema? Then you already have a binary format. Install shorn and pass that schema to encode and decode. There is no schema file to write, no code to generate, and nothing to migrate.

// The validator you already use.const Person = z.object({  name: z.string(),  age: z.int().nonnegative(),  sex: z.enum(["M", "F", "X"]),}); // It is also the wire format.const bytes = encode(Person, person);// → 8 canonical bytes, validated before encoding const back = decode(Person, bytes);// → { name: string; age: number; sex: "M" | "F" | "X" }
Read the docs npm install @chichurita/shorn
The field names stay in your schema, so only the values go on the wire. A field is identified by its position, not by its name. Nothing was lost: the names are still in your schema, where they were the whole time.

Install it. Keep your schema.

shorn reads the wire layout from your schema. Zod, Valibot, or ArkType still owns validation, transformations, and error messages, exactly as before.

shorn
Nothing to set up. Call encode(schema, value) and decode(schema, bytes) with the validator you already have.
Avro
Write and maintain a second schema in Avro's own format, then build an Avro codec from it.
SchemaPack
Describe the same shape a second time with SchemaPack's builder.
Protobuf
Write a .proto file, then either generate code from it or set up runtime reflection.
That same Person record, encoded by each of them.
shorn 8
Avro (own schema) 8
SchemaPack (own builder) 9
Protobuf (.proto + codegen) 11
msgpackr plain (field names) 25
JSON (field names + text) 35
shorn matches Avro's eight bytes without asking you to write an Avro schema.

Try it

Edit the Zod schema and the payload. The real encoder runs in your browser tab, and nothing you type leaves it.

Tab indents. Press Escape first to tab out of the box.

loading the encoder…

Built for shared TypeScript schemas

Use it where both ends are TypeScript and share the schema: caches, RPC, worker messages, job queues, and compact application storage.

Use something else when a reader is in another language, when a payload has to describe itself, or when the two ends need to change schemas independently. fingerprinted() stamps the wire shape into the bytes, so a payload written by an older shape is rejected instead of decoded into the wrong value.

Safe to put on the wire

Canonical bytes

The same value always encodes to exactly the same bytes, so a payload can serve as a cache key or a content hash.

Defensive decoding

Decoding bytes you did not write is where binary formats get exploited. The decoder checks every length against the input before it allocates, and rejects invalid UTF-8 and leftover bytes.

Smallest or tied in every raw fixture

Small payloads normally cost CPU, because a compressor has to produce them. These come from the schema instead, in the same pass that encodes the value.

Person  { age, name, sex }
Event   { active, actor: Person, id,
          metrics: { cpu, memory }, tags[], timestamp }

Four fixtures: a Person, the same Person with non-ASCII text, one Event, and a batch of 100 Events in an array.

4.2 MB
for 100,000 events that take 16 MB as JSON, before any compressor runs. Gzipping those shorn bytes afterwards costs a further 48 ms of CPU.
up to 6.2×
faster than JSON to encode, and up to 13.7× faster to decode.
up to 77%
smaller than JSON before compression.

Read the results, caveats, and reproduction steps.