Skip to content

Getting started

This guide takes you from one install to a working round-trip, swaps the format behind the same seam, and finishes by implementing the interface yourself.

The umbrella package is the interface; a working encoder/decoder is one adapter install away. Start with JSON — it brings the interface package with it:

Terminal window
bun add @insler/serde-json

A serde is two methods: encode a value to a wire format, decode it back. jsonSerde is SuperJSON-backed, so rich types plain JSON cannot carry — Date, Map, Set, BigInt, RegExp — survive:

import { jsonSerde } from '@insler/serde-json';
const wire = jsonSerde.encode({ createdAt: new Date(), tags: new Set(['a', 'b']) });
// SuperJSON string
const value = jsonSerde.decode(wire);
// { createdAt: Date, tags: Set(['a', 'b']) }

Every implementation honors the same edge contract: encode(undefined) produces an empty wire, and decoding an empty wire returns undefined.

Binary transports want bytes, not strings. The JSON adapter ships jsonBytesSerde (Serde<Uint8Array>), and the MessagePack and CBOR adapters are drop-in replacements behind the same type:

Terminal window
bun add @insler/serde-msgpack @insler/serde-cbor
import type { Serde } from '@insler/serde';
import { jsonBytesSerde } from '@insler/serde-json';
import { msgpackSerde } from '@insler/serde-msgpack';
import { cborSerde } from '@insler/serde-cbor';
// All three are Serde<Uint8Array> — pick one, the call sites never change.
const serde: Serde<Uint8Array> = msgpackSerde;
const bytes = serde.encode({ id: 'ord-1', quantity: 3 });
const order = serde.decode(bytes);

This is the point of the interface: a transport (for example the rpc subsystem’s NATS transport) takes any Serde<Uint8Array> as its serde option, so swapping formats is a one-argument change.

Unlike the schemaless formats, Avro is schema-required — the adapter exports a factory. Supply an Avro schema and get back a standard Serde<Uint8Array>, interchangeable with the other binary adapters:

Terminal window
bun add @insler/serde-avro
import { createAvroSerde, type AvroSchema } from '@insler/serde-avro';
const orderSchema: AvroSchema = {
type: 'record',
name: 'Order',
fields: [
{ name: 'id', type: 'string' },
{ name: 'quantity', type: 'int' },
],
};
const avroSerde = createAvroSerde(orderSchema);
const bytes = avroSerde.encode({ id: 'ord-1', quantity: 3 });
const order = avroSerde.decode(bytes);

Both sides of a wire construct their serde from the shared schema — two serdes from the same schema are wire-compatible. Avro schemas are this adapter’s local concern; they are not the rpc contract’s zod schemas.

The interface lives in the zero-dependency core. Implement its two methods and your format plugs in everywhere a Serde is accepted — preserve the undefined ↔ empty-wire round-trip:

Terminal window
bun add @insler/serde
import type { Serde } from '@insler/serde';
const plainJson: Serde<string> = {
encode: (value) => (value === undefined ? '' : JSON.stringify(value)),
decode: (wire) => (wire === '' ? undefined : JSON.parse(wire)),
};

Wire is the only knob — encode takes unknown, decode returns unknown, and the type parameter is whatever your format puts on the wire.

  • The interface. @insler/serde — the Serde<Wire> contract every adapter implements.
  • The adapters. JSON, MessagePack, CBOR, and Avro — one page per package.
  • Put it on a wire. The rpc subsystem takes any of these as a transport’s serde option — and the rest of the insler.dev family composes the same way.