CBOR

CBOR — Concise Binary Object Representation, RFC 8949 — lives in runtime:serialization and runs in Rust: bytes in, a JavaScript value out, with no npm package to add. It shares MessagePack's binary footprint but carries a wider range of values and a defined deterministic form, which is what this implementation encodes.

Decoding CBOR

Use CBOR.decode to turn a CBOR byte array into a JavaScript value.

JavaScript
import { CBOR } from "runtime:serialization";

const bytes = new Uint8Array([
  0xa1, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64,
]);

const value = CBOR.decode(bytes);
console.log(value); // { hello: "world" }

Both definite-length and indefinite-length strings, arrays and maps are accepted, so a document produced by any conforming encoder reads back. The whole input must be consumed: a trailing byte is a SyntaxError, not silently ignored.

Encoding CBOR

Use CBOR.encode to write a JavaScript value as CBOR bytes.

JavaScript
import { CBOR } from "runtime:serialization";

const bytes = CBOR.encode({ hello: "world" });
console.log(bytes); // Uint8Array(13) [161, 101, 104, 101, 108, 108, 111, 101, 119, 111, 114, 108, 100]

Encoding is the deterministic form of RFC 8949 §4.2:

  • integers and lengths use the shortest form that holds the value (the integer forms cover their full ranges; major type 1 reaches −264);

  • a float uses the shortest width that preserves it exactly — half, single, or double — with the canonical half-precision NaN f97e00;

  • map keys are sorted by the bytewise lexicographic order of their encodings.

The same value therefore always encodes to the same bytes. Because keys are sorted, a document you encode and decode comes back with its keys in canonical order, not the order you wrote them in:

JavaScript
const bytes = CBOR.encode({ b: 1, a: 2, aa: 3 });
// Keys sort by their encoded bytes: the length prefix sorts first, so the order
// is "a", "b", "aa" — not the insertion order, and not plain lexicographic order.
console.log([...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" "));
// a3 61 61 02 61 62 01 62 61 61 03

What maps to what

An integer up to 253 − 1 comes back a JavaScript number; the first integer past that is a BigInt, so no CBOR integer is ever rounded. encode writes a BigInt back as an integer while it fits the range the major types cover, [−264, 264-1]; a BigInt beyond that would need a bignum tag, which is refused, so it throws.

JavaScriptWireComes back as
Uint8Array, any typed-array view, ArrayBufferbyte string (major 2)Uint8Array
Mapmap, keys stringifiedplain object
SetarrayArray
Datetext string (ISO 8601)string, not a Date
undefined0xf7undefined
null0xf6null
BigIntinteger (major 0/1), within ±264bigint past 253, else number
function, symbol—throws TypeError

Map, Set and ArrayBuffer are converted because they reach the host as empty objects — they carry no own enumerable properties, so without the conversion every entry would be silently dropped. A non-string CBOR map key is coerced the way a JavaScript property access coerces it — numbers with String(n), arrays and byte strings as comma-joined elements, a nested map as [object Object] — so keys that coerce alike share a name. An integer-valued number encodes as an integer, so a float that happens to be whole (a decoded 100000.0) re-encodes as the integer 100000. A value with no CBOR form throws rather than being written as null, since a silent substitution is exactly the data loss a format chosen for fidelity exists to avoid.

Tags are refused

CBOR's major type 6 attaches a tag — a number giving a value a semantic meaning, such as a date (0/1), a bignum (2/3), or a decimal fraction (4). No JavaScript type corresponds to a CBOR tag, and inventing a wrapper object would make a tagged value indistinguishable from real data the document contained. So decode refuses a tag and reports its number, and encode never writes one:

JavaScript
CBOR.decode(new Uint8Array([0xc0, 0x61, 0x61]));
// SyntaxError: Parse failed: unsupported tag 0 (tags have no JavaScript counterpart)

The same reasoning refuses an unassigned simple value (major type 7), for which there is likewise no JavaScript counterpart. Tags are a candidate to revisit — a future Date mapping would decode tag 1, for instance — but the readiness check and the encoder would both have to change together.

Validating CBOR

CBOR.validate checks a byte array without your having to catch a throw. It reads the document exactly as decode does, so the two always agree: a document the decoder would refuse (a tag, a trailing byte) is not reported valid.

JavaScript
import { CBOR } from "runtime:serialization";

CBOR.validate(new Uint8Array([0x00])); // true
CBOR.validate(new Uint8Array([0xc0, 0x61, 0x61])); // false — a tag

const result = CBOR.validate(new Uint8Array([0x00, 0x00]), { detailed: true });
console.log(result.valid); // false
console.log(result.error); // "Validation failed: bytes remain after the top-level value"

How it compares to MessagePack

MessagePack and CBOR cover much the same ground, and this module treats them identically where they overlap: a byte string decodes to a Uint8Array, a Map and a Set are converted rather than flattened, and an unrepresentable value throws. The differences are the ones the formats themselves have:

MessagePackCBOR
Encodingshortest forms, but no defined canonical outputdeterministic (RFC 8949 §4.2)
undefinedencoded as nilundefined (0xf7), distinct from null
Extensionsext, decoded as raw bytestags, refused
Float widthalways float64shortest (half / single / double)
Speca compact de-facto standardan IETF standard (RFC 8949)

Reach for CBOR when a standard's canonical form matters — a signature or a content address over the encoded bytes — or when you need undefined to survive distinctly from null. Reach for MessagePack when you want the ext family to pass through as bytes.

How it compares to other libraries

CBOR is an IETF standard, so the encoders agree on the wire and part company on what JavaScript types come back — and, past the safe integer range, on whether the value survives at all.

Outside-safe integersTagsCanonical outputShips as
ES-Runtimebigint (past 253 − 1)refusedalwaysruntime:serialization
Deno @std/cborbigint (from 232)CborTagno (always float64)a JSR package
cborgbigint (past ±253)Tagsmallest forms, map order freean npm package
cbor-xbigint (opt out with int64AsNumber)extension tagsno (record extension)an npm package
node-cborBigInt (bignum tags 2/3)to native typesopt-in (encodeCanonical)an npm package

The integer boundary here is the field's: as in cborg and cbor-x, a small integer stays a number and the first one past 253 − 1 becomes a BigInt, so the exact value survives a round trip. Deno's @std/cbor draws the line lower, at 232, so it returns a bigint for a value this keeps as an exact number. A BigInt inside the safe range encodes as an integer and comes back a number — the wire has no BigInt type to return — which is the same round-trip cborg documents.

Two differences are deliberate. The 64-bit range is the end of the road: a value past 264-1 or −264 throws, where the others carry it in a bignum tag (2/3) — the same refusal a Date gets, for the same reason. And the canonical form is what encode always writes rather than an option you turn on (node-cbor's encodeCanonical), because a deterministic encoding is the point of choosing CBOR here.

The benchmarks measure this decoder against the facility each other runtime ships — esrun's native CBOR, Deno's @std/cbor, and cbor-x on Node and Bun — from the committed results rather than a number typed here.

See also

Last updated on
Edit this page