Composable, type-safe binary codecs for TypeScript & JavaScript.
Define your wire format once as a value. Get a fully-typed encode/decode pair to and from Uint8Array — no schema files, no code generation, no decorators.
Small codecs snap together into big ones, and TypeScript infers the exact shape of everything you build.
import { ArrayCodec, Str, StructCodec, U32, U8 } from "@nomadshiba/codec";
const User = new StructCodec({
id: U32,
name: Str,
roles: new ArrayCodec(U8),
});
const bytes = User.encode({ id: 1, name: "Ada", roles: [0, 3] });
const [user] = User.decode(bytes);
// user: { id: number; name: string; roles: number[] }- Composable by construction. Every codec is a first-class value. Nest structs in arrays in enums in nullables — it's just function calls, and the types follow.
- Type-safe, no boilerplate. Your codec is your type.
Codec.InferInput/Codec.InferOutputderive the TS types straight from the definition, so they can never drift out of sync. - You own the bytes. Big-endian by default, little-endian variants,
VarInt, fixed-size framing, padded enums — the encoded layout is exactly what you specify. Great for protocols, file formats, and on-chain/compact payloads. - Fast paths built in. Zero-copy decoding for bytes and strings (returns subarray views), and
encodeIntowrites directly into a buffer you already allocated — no throwaway allocations in hot loops. - Runs anywhere. Deno, Node, Bun, browsers. Published on JSR, no runtime dependencies beyond the standard library.
deno add jsr:@nomadshiba/codecOther package managers
npx jsr add @nomadshiba/codec # npm
pnpm i jsr:@nomadshiba/codec # pnpm >=10.8
pnpm dlx jsr add @nomadshiba/codec # pnpm <10.8
yarn add jsr:@nomadshiba/codec # yarn >=4.8
yarn dlx jsr add @nomadshiba/codec # yarn <4.8
bunx jsr add @nomadshiba/codec # bun
vlt install jsr:@nomadshiba/codec # vltEvery codec extends Codec<O, I> — O is the type you get from decode, I the type you pass to encode (defaults to O). O must extend I: encode
can be lenient and accept several input forms, while decode returns one canonical type that's always assignable back to what encode takes. Four members
matter:
| Member | Signature | What it does |
|---|---|---|
encode |
(value: I) => Uint8Array |
Encode into a freshly allocated buffer. |
encodeInto |
(value: I, target: Uint8Array, offset?: number) => number |
Encode in place into target; returns bytes written. |
decode |
(data: Uint8Array, offset?: number) => [O, number] |
Decode from offset; returns [value, bytesConsumed]. |
stride |
{ kind: "fixed"; size } | { kind: "variable" } |
Whether encoded size is constant or value-dependent. |
decode returning the byte count means you can read many values back-to-back from one buffer by advancing the offset yourself. stride is what lets composites
know their own size at build time — it's how FixedEnumCodec and fixed-size framing work.
import { Codec, Str, StructCodec, U32 } from "@nomadshiba/codec";
const User = new StructCodec({ id: U32, name: Str });
type User = Codec.InferOutput<typeof User>; // { id: number; name: string }
type UserIn = Codec.InferInput<typeof User>; // { id: number; name: string }Fixed-width numbers and booleans, plus a zero-byte Void. Each has a ready-made singleton (U32) and an underlying class (U32Codec) for when you
need options.
| Codecs | Decoded type |
|---|---|
U8 I8 U16 I16 U32 I32 |
number |
U64 I64 |
bigint |
F32 F64 |
number |
Bool |
boolean |
Void |
undefined (0 B) |
VarInt |
number (LEB128) |
BigVarInt |
bigint (LEB128) |
Big-endian is the default. Every 16-bit-and-wider codec ships a *LE little-endian singleton, or pass { endian: "le" } to the class:
import { U16LE, U32Codec } from "@nomadshiba/codec";
U16LE.encode(0xABCD); // little-endian singleton
const u32le = new U32Codec({ endian: "le" }); // or construct your ownVarInt uses LEB128, so small numbers cost fewer bytes (0–127 → 1 byte, up to 16383 → 2 bytes, …). BigVarInt is the same encoding with no upper bound, backed
by bigint.
UTF-8 with a length prefix. Str is the ready-made singleton (a new StringCodec()); reach for the class only to change the framing.
import { Str, StringCodec, U32 } from "@nomadshiba/codec";
Str.encode("hello"); // [0x05, "hello"] — VarInt length prefix
new StringCodec({ sizer: U32 }); // fixed 4-byte length prefix instead of VarInt
new StringCodec({ size: 36 }); // exactly 36 UTF-8 bytes, no prefix (throws on mismatch)Raw Uint8Array, same framing options. Decoding is zero-copy — you get a subarray view into the source buffer.
import { Bytes, BytesCodec } from "@nomadshiba/codec";
Bytes.encode(new Uint8Array([1, 2, 3])); // VarInt-prefixed
new BytesCodec({ size: 32 }); // fixed 32-byte field, e.g. a key or hashFields are encoded in definition order as a flat concatenation. All fields required. stride is "fixed" when every field is fixed-size.
import { F32, StructCodec } from "@nomadshiba/codec";
const Point = new StructCodec({ x: F32, y: F32 });
Point.encode({ x: 1, y: 2.5 }); // 8 bytes, no framing overheadReordering fields changes the binary layout and breaks compatibility with previously encoded data.
Same as Struct, but append ? to any key to make that field optional. Optional fields get a one-byte presence flag on the wire (0x00 absent, 0x01
present) and become ?: in the inferred type — required fields stay required.
import { Codec, ModelCodec, Str, U32, U8 } from "@nomadshiba/codec";
const User = new ModelCodec({
id: U32,
name: Str,
"age?": U8, // optional
"bio?": Str, // optional
});
User.encode({ id: 1, name: "Ada" }); // age + bio omitted from bytes
User.encode({ id: 1, name: "Ada", age: 30, bio: "hi" }); // all present
type User = Codec.InferOutput<typeof User>;
// { id: number; name: string; age?: number; bio?: string }Use Struct when every field is always present; reach for Model the moment you need optionality. Both expose .shape, and both have .partial() — returning
a ModelCodec where every field is optional (handy for PATCH-style updates):
const Patch = User.partial();
Patch.encode({ name: "Ada" }); // encode just the fields you haveCount prefix (VarInt by default) followed by the elements.
import { ArrayCodec, U16, U32 } from "@nomadshiba/codec";
new ArrayCodec(U16).encode([1, 2, 3]); // [count, ...elements]
new ArrayCodec(U16, { counter: U32 }); // fixed 4-byte count instead of VarIntElements concatenated with no wrapper of its own.
import { Str, TupleCodec, U8 } from "@nomadshiba/codec";
new TupleCodec([U8, Str]).encode([7, "hi"]); // [0x07, 0x02, "hi"]Presence byte, then the value. Fixed-size inners stay fixed-size (null is zero-padded), so it's safe inside padded layouts.
import { NullableCodec, U8 } from "@nomadshiba/codec";
const MaybeU8 = new NullableCodec(U8);
MaybeU8.encode(null); // [0x00, 0x00]
MaybeU8.encode(7); // [0x01, 0x07]Both use a presence byte, but they exist for opposite goals — fixed layout vs. dynamic packing.
A Model optional field ("key?") wants to be dynamic: it packs only what's actually there. Absent → a single 0x00 and nothing else (the inner codec never
runs, no space reserved); present → 0x01 then the value. The encoded size depends on which fields you supply, which is exactly why any optional field makes
the struct variable. Compact, at the cost of a predictable layout.
Nullable with a fixed inner refuses to be dynamic: it keeps the slot reserved either way. null still writes the presence byte plus a zero-padded
payload, so the field is always 1 + inner.size bytes whether or not there's a value. That constant footprint is what lets the stride stay fixed — every
record lands at the same offset, so you can index or memory-map arrays of them.
new ModelCodec({ "age?": U8 }); // absent = 1 byte; the U8 is dropped → variable
new NullableCodec(U8); // null = 2 bytes; the U8 slot is zeroed → fixedSo: reach for an optional field to avoid spending bytes on data that isn't there; reach for Nullable when you want the shape to never move. The fixed
behaviour only applies to a fixed inner — give Nullable a variable inner (a string, an array) and there's nothing to pad to, so it falls back to the dynamic
form (0x00 for null, 0x01 + value otherwise) and goes variable like Model.
A discriminant index (default U8, up to 256 variants) plus the selected variant's payload. Decodes to { kind, value }.
import { EnumCodec, Str, U8 } from "@nomadshiba/codec";
const Event = new EnumCodec({
Click: U8, // index 0
Message: Str, // index 1
});
Event.encode({ kind: "Click", value: 5 });
Event.encode({ kind: "Message", value: "hello" });Variant order fixes the indices. Adding/removing variants shifts them and breaks old data; renaming is safe.
Like Enum, but every variant must be fixed-size and all encode to the same constant length (shorter payloads are zero-padded), so the whole codec stays
fixed. Useful when you need uniform records — arrays of enums, memory-mappable formats, fixed-size packets.
import { FixedEnumCodec, U16, U8 } from "@nomadshiba/codec";
const E = new FixedEnumCodec({ Click: U8, Scroll: U16 });
// stride: fixed 3 bytes (1 index + 2 max payload)
E.encode({ kind: "Click", value: 5 }); // [0x00, 0x05, 0x00] ← padded
E.encode({ kind: "Scroll", value: 300 }); // [0x01, 0x01, 0x2C]The fixed-size requirement is guarded both ways: variants are constrained to fixed-size codecs at the type level, and the constructor throws if a
variable-stride variant (or a variable-stride custom indexer) slips through. For variable-length variants, use Enum instead.
Renamed from
PaddedEnumCodecin 0.6.0. The oldPaddedEnum*names still work as deprecated aliases.
.transform() wraps any codec with a post-decode step. Encoding is unchanged; decoding runs your function on the result. The transformed type must extend the
codec's output type, so you can narrow, brand, validate, attach methods, or capture the raw bytes — but never wander off into an unrelated type.
import { F32, StructCodec, U64 } from "@nomadshiba/codec";
// u64 milliseconds → Date
const Timestamp = U64.transform((ms) => new Date(Number(ms)));
Timestamp.encode(BigInt(Date.now())); // still a plain u64 on the wire
const [when] = Timestamp.decode(bytes); // Date
// validate on the way in
const SmallU32 = U32.transform((n) => {
if (n > 1000) throw new Error("out of range");
return n;
});
// attach behaviour + keep the raw bytes
const Point = new StructCodec({ x: F32, y: F32 });
const RichPoint = Point.transform((p, bytes) => ({
...p,
raw: bytes,
dist() {
return Math.hypot(this.x, this.y);
},
}));The result is a TransformCodec; its .inner holds the codec you wrapped.
When the building blocks aren't enough, extend Codec<O, I> and implement stride, encoder, and decoder. The base class turns those into encode,
encodeInto, and decode for you. Declare implements FixedCodec<O, I> for fixed-size codecs so TypeScript checks your stride.
import { Codec, type FixedCodec, type Stride, U64 } from "@nomadshiba/codec";
class DateCodec extends Codec<Date, Date | bigint> implements FixedCodec<Date, Date | bigint> {
readonly stride: Stride<"fixed"> = { kind: "fixed", size: 8 };
encoder(value: Date | bigint, target: undefined, offset: undefined): Uint8Array<ArrayBuffer>;
encoder(value: Date | bigint, target: Uint8Array, offset: number): number;
encoder(value: Date | bigint, target?: Uint8Array, offset?: number): Uint8Array<ArrayBuffer> | number {
const ms = typeof value === "bigint" ? value : BigInt(value.getTime());
return target === undefined ? U64.encode(ms) : U64.encodeInto(ms, target, offset);
}
decoder(data: Uint8Array, offset: number): [Date, number] {
const [ms, size] = U64.decode(data, offset);
return [new Date(Number(ms)), size];
}
}encoder is overloaded on purpose: called with no target it allocates and returns a Uint8Array; called with a target/offset it writes in place and
returns the byte count — the same dual mode that powers encodeInto throughout the library.
The two type parameters are O (decoded output) and I (encoded input, defaults to O), and O must extend I. That lets encode accept a wider type
than decode returns — the DateCodec above takes Date | bigint on the way in but always hands back a Date.
For most code, Codec.InferInput / Codec.InferOutput are all you need. When you write functions over codecs, each composite also exports a *Generic
constraint plus *Input<T> / *Output<T> pairs:
import { ArrayCodec, type ArrayGeneric, type ArrayOutput } from "@nomadshiba/codec";
function decodeAll<T extends ArrayGeneric>(codec: ArrayCodec<T>, data: Uint8Array): ArrayOutput<T> {
return codec.decode(data)[0];
}The full set: Nullable, Tuple, Struct, Model, Array, Enum, FixedEnum — each with *Generic / *Input / *Output.
See the migrations folder for per-version upgrade notes.