Skip to content

Compared with alternatives ​

protoarray sits where three kinds of tools meet: JSON and the libraries that make it typed or smaller (fast-json-stringify, superjson, devalue, JSONH, compress-json), the interface definition languages built for schema evolution (Protocol Buffers through protobufjs, protobuf-es and ts-proto, Avro, Thrift, the zero-copy FlatBuffers and Cap'n Proto, Bebop, typia's compiled codecs, schemapack), and the schemaless binary formats (MessagePack through msgpackr and @msgpack/msgpack, CBOR, BSON, Node's v8.serialize, Amazon Ion). The table below compares it with twenty-two of them, every feature in one table, rather than a few convenient libraries in a few convenient rows.

What the table shows ​

  • Positional JSON is a small corner. Eleven of the twenty-three travel over a JSON or text channel as they are, and protoarray is the only one of them that writes neither the names of the fields nor numbers in their place: the others write the names (or Thrift's field ids) in every object, or once per list (JSONH, compress-json) with no schema to check the values against. The formats that drop the names (tag numbers, positions, vtable slots) are binary, Thrift's JSON protocol aside, and need base64 on a channel that carries text.
  • Evolution past append-only is rare. Every format with a schema lets you add fields. Reading an older writer's payload into the current reader's shape, with renames, defaults and widened types, is Avro's (createResolver) and protoarray's (resolve); Protocol Buffers and Thrift match tag numbers and skip what they do not know. Only avsc and protoarray compute a fingerprint of a schema, and only protoarray has a registry and an envelope built in; the Protocol Buffers libraries have Any and a third-party registry.
  • Decoding untrusted input differs more than the speed does. Eleven of the twenty-three overflow the stack with a raw RangeError on deeply nested input, devalue at about 2,200 levels and v8.deserialize at about 4,150. In five a __proto__ key in the payload replaces the prototype of a decoded object (JSONH, compress-json, the maps of ts-proto and avsc, typia's protobuf records). FlatBuffers, Bebop and Ion's binary reader turn a truncated or corrupt buffer into garbage or null without an error. protoarray takes its keys from the schema, checks the wire by default and turns an overflow into a DecodeError with a path.
  • Encoding trusts the values almost everywhere. fast-json-stringify, Thrift, FlatBuffers, Cap'n Proto and Bebop write a string given for an integer as 0, or coerce it, without an error; protobufjs's encode coerces too and checks with a separate verify. protoarray trusts its values as well, which is the speed the benchmarks measure, and checking them is metaschema's check of the same definition, with a benchmark row for what it costs.
  • Code generation is a trade. avsc and schemapack cannot run where new Function is forbidden (avsc cannot even be loaded), and protobufjs's reflection, msgpackr's and cbor-x's records compile code and fall back, or need static code. ts-proto, Thrift, FlatBuffers, Cap'n Proto and typia need a native compiler or a TypeScript transform in the build. protoarray reads its schemas at runtime, generates code where it may, falls back to closures, and writes standalone modules for the runtimes that allow no eval.
  • Columns and differences are protoarray's alone in this set; the Protocol Buffers libraries, FlatBuffers and the record extensions of msgpackr and cbor-x save bytes other ways (packed scalars, shared strings, structures written once).
  • Some are no longer moving. schemapack (last release 2016-08), JSONH (2017-07) and Amazon Ion's JavaScript implementation (2024-04).

protoarray's own gaps are in the table as such: JavaScript is its only implementation (the wire is specified, nobody else reads it yet), it has no zero-copy reads, an absent field and null are the same on the wire, and parse itself has no length limit (the stream parsers do).

How the table was made ​

  • From the code, not the README. Each library was installed at its latest version from npm (October 2026) and every cell decided by reading its source, and where the behaviour was not obvious from reading (a __proto__ key in a payload, 100,000 levels of nesting, a truncated buffer, a wrong type given to encode), by running a script against it. A README claim the code does not back is not counted, and the footnotes say where the two disagree.
  • Generated code without native compilers. protoc plugins (protoc-gen-es, ts-proto) were run on a CodeGeneratorRequest built by hand and pbjs on a .proto; for Thrift, FlatBuffers, Bebop and typia the generated code was written in the shape their generators emit, read from the generators' source, and run against the real npm runtimes.
  • Built in or official. A feature counts as ✅ when the package has it or an official package of the same project does (named in the cell); a third-party package is ⚠️ and named. A ❌ means it is not built in: most of them can be written by hand on top.
  • The same questions for everyone. The rows were fixed before any column was filled, and a row that does not apply to a kind of tool (a schema question for a schemaless format) is marked — rather than ❌.

The speed of the libraries that can be benchmarked side by side, their cold start and their bundle sizes are on the Benchmarks page, with the method; this page is about what each one does.

The matrix ​

Featureprotoarray
positional JSON
JSON
built in
fast-json-stringify
JSON Schema serializer
superjson
typed JSON
devalue
typed JSON
JSONH
packed JSON
compress-json
compressed JSON
protobufjs
IDL · Protobuf
protobuf-es
IDL · Protobuf
ts-proto
Protobuf codegen
avsc
IDL · Avro
Apache Thrift
IDL · Thrift
FlatBuffers
IDL · zero-copy
Cap'n Proto
IDL · zero-copy
Bebop
IDL · binary
typia
AOT compiler
schemapack
schema · binary
msgpackr
MessagePack
@msgpack/msgpack
MessagePack
cbor-x
CBOR
BSON
BSON
v8.serialize
built in · Node
Amazon Ion
self-describing
Package
Version compared2.0.0Node 24.14.1 (V8 13.6)7.1.02.2.66.0.30.0.63.4.08.8.1 (protobufjs-cli 2.7.1)2.16.0 (protoc-gen-es 2.16.0)2.15.35.7.90.25.025.9.230.0.1613.2.315.1.01.4.22.1.03.1.31.6.67.3.3Node 24.14.1 (format version 15)5.2.1
Latest release2026-10 (this release)Node 24 (built in)2026-102025-112026-102017-072025-092026-102026-092026-102025-072026-092025-092026-082025-092026-102016-082026-082025-122026-082026-09Node 24 (built in)2024-04
Runtime dependencies✅ 0✅ 0 (built in)6: ajv, ajv-formats, fast-uri, rfdc, …1: copy-anything✅ 0✅ 0✅ 01: long✅ 01 at runtime: @bufbuild/protobuf✅ 05: ws, uuid, node-int64, isomorphic-ws, browser-or-node✅ 0✅ 0 (an optional peer for the compiler)✅ 03: @typia/interface, @typia/utils, randexp + optional peer ttsc✅ 01 optional: msgpackr-extract (native)✅ 01 optional: cbor-extract (native)✅ 0✅ 0 (built in)✅ 0
LicenseMITbuilt in (ECMA-262)MITMITMITMIT (source header)BSD-2-ClauseBSD-3-ClauseApache-2.0 AND BSD-3-ClauseISCMITApache-2.0Apache-2.0MITApache-2.0MITMITMITISCMITApache-2.0MIT (Node.js)Apache-2.0
Runtime
Runs on✅ Node 18+, browsers, edge✅ every JS runtime✅ Node, browsers (bundled with Ajv)✅ Node ≥ 16, browsers✅ Node 20+, browsers, Deno, Bun, edge✅ Node, browsers (ES3), Deno, Bun, edge✅ Node, browsers (IIFE bundle), Deno, Bun✅ Node ≥ 12, browsers, Deno, Bun✅ Node, browsers, Deno, Bun, edge✅ anywhere @bufbuild/protobuf/wire runs⚠️ Node ≥ 0.11; browsers need buffer/util/stream shims2⚠️ Node ≥ 10.18; browsers need util/Buffer shims3✅ Node, browsers, Deno, Bun (no engines)✅ Node, browsers, Deno, Bun (no engines)✅ Node, browsers, Deno, Bun (no engines)✅ Node, browsers (build needs ttsc, Node ≥ 22.15)⚠️ Node; browsers need the buffer shim4✅ Node, browsers, Deno, Bun✅ Node ≥ 18, browsers, Deno, Bun✅ Node, browsers, Deno✅ Node ≥ 20.19, browsers, React Native❌ Node only (node:v8)✅ Node, browsers (no engines, needs BigInt)
Module formatCJS, ESM named importsbuilt in (global JSON)CJSESM onlyESMCJS + global script (JSONH)CJS (+ IIFE browser bundle)CJS + UMD bundle (dist/protobuf.js)ESM + CJSgenerated .ts (ESM imports); plugin CJSCJSCJS (generated code CJS or ESM)CJS + ESM (module field)ESMESM + CJSESM + CJSCJSESM + CJS (+ UMD)ESM + CJS + UMDESM + CJS (+ UMD)ESM + CJSbuilt in (node:v8)CJS + ESM (bundlers) + AMD
Works without eval (strict CSP, edge)✅ closures fallback; standalone modules at codegen speed✅ native, no code generation⚠️ build uses new Function; standalone modules don't5✅ no eval or Function in dist/✅ stringify/parse never eval✅ never✅ never⚠️ reflection needs new Function; static code doesn't6✅ never: closures over descriptors✅ never: plain generated code❌ new Function per type; required7✅ never (library and generated code)✅ never (runtime and flatc output)✅ never✅ never (runtime and bebopc output)✅ never (code emitted at build time)❌ new Function per build()8⚠️ new Function for records; catches failure9✅ never⚠️ probes new Function at import; compiles records10✅ never (Code values are not evaluated)✅ native, no code generation✅ never
Schema at runtime, no code generator✅ a definition or a snapshot at runtime✅ no schema needed✅ JSON Schema object at runtime✅ no schema needed✅ no schema needed✅ no schema needed✅ no schema needed✅ protobuf.parse/load or Root.fromJSON at runtime⚠️ createFileRegistry from descriptors; no .proto parser❌ needs protoc/buf + plugin✅ Type.forSchema(json) at runtime❌ needs thrift --gen js:node❌ requires flatc code generation❌ requires C++ capnpc + capnp-es plugin⚠️ BinarySchema reader/writer; schema from bebopc❌ every function throws without the ttsc transform✅ build(schema) at runtime✅ no schema needed✅ no schema needed✅ no schema needed✅ no schema needed✅ no schema needed✅ no schema needed
TypeScript types✅ bundled d.ts, types from the metaschema schema✅ TypeScript's lib.es5.d.ts✅ bundled d.ts✅ bundled d.ts✅ bundled d.ts❌ none (no @types/jsonh)✅ bundled d.ts✅ bundled d.ts; pbts for messages✅ bundled d.ts; generated message types✅ generated from the IDL✅ bundled d.ts (types/index.d.ts)✅ generated from the IDL (js:ts)✅ generated from the IDL (flatc --ts)✅ generated from the IDL (-ots, -odts)✅ generated from the IDL (bebopc)✅ bundled d.ts (the types are the schema)❌ none (no @types/schemapack)✅ bundled d.ts✅ bundled d.ts (written in TypeScript)✅ bundled d.ts✅ bundled d.ts⚠️ @types/node✅ bundled d.ts
Schema
How the shape is describedmetaschema definition (a JS object) or its JSON snapshotnone (schemaless)JSON Schema (draft 7, plus nullable)none (schemaless)none (schemaless); reducers/revivers for custom typesnone; keys from the first recordnone (schemaless).proto file, JSON descriptor or reflection API.proto file, compiled to descriptors.proto file (via protoc)Avro schema JSON (or .avdl IDL).thrift IDL.fbs IDL (flatc also reads .proto).capnp IDL.bop IDLTypeScript type (+ tags.*)JS object of type names ({ age: 'uint8' })none (records inferred from object keys)none (schemaless)none (records inferred from object keys)none (schemaless)none (schemaless)none (self-describing)
The schema is data (store it, send it)✅ the snapshot is JSON a server hands to clients— schemaless✅ JSON Schema is plain data11— schemaless— schemaless— schemaless— schemaless✅ JSON descriptors: root.toJSON() / Root.fromJSON✅ FileDescriptorSet → createFileRegistry⚠️ export only (outputSchema=true)✅ Avro schema JSON; type.schema() exports it❌ generated code only⚠️ export only: .bfbs, JSON Schema via flatc⚠️ schema nodes as capnp messages; no dynamic codec✅ binary schema + BinarySchema (RecordReader)⚠️ export only: json.schema<T>(), protobuf.message<T>()⚠️ plain JSON, but keys become code— no schema— no schema— no schema— no schema— schemaless— schemaless
Static type from the schema✅ from the metaschema schema (Standard Schema types)— schemaless⚠️ third-party: TypeBox, json-schema-to-ts— schemaless— schemaless; parse returns any— schemaless— schemaless; decompress<T> is a cast✅ generated by pbts from static code✅ generated by protoc-gen-es✅ generated interfaces⚠️ third-party @ovotech/avro-ts codegen✅ generated d.ts (js:ts)✅ generated by flatc --ts✅ generated by capnp-es -ots✅ generated by bebopc✅ the TS type is the schema❌ none— no schema— no schema— no schema— no schema— schemaless— schemaless
Values checked before encode✅ metaschema check of the same definition12— schemaless❌ coerces: '12' → 12, null → 013— schemaless— schemaless— schemaless— schemaless✅ Type.verify; encode coerces silently⚠️ toBinary range-checks numbers; strings coerced14⚠️ writer range-checks numbers; no verify15✅ type.isValid(v, { errorHook }); toBuffer throws❌ string in i32 → 0, 2^40 → 016❌ coerces: string in int → 0, overflow wraps17❌ coerces: string in UInt32 → 018❌ trusts: string in uint32 → 019✅ assertEncode, assertStringify, typia.assert⚠️ typeof and range checks, on by default20— no schema— no schema— no schema— no schema— schemaless— schemaless
Wire format
Wire formatJSON text (positional arrays)JSON textJSON textJSON text + metadata ({ json, meta })JSON text (flat value table, index refs)21JSON text ([n, …keys, …values])JSON array ([values[], rootKey], encoded strings)binary (Protobuf); ProtoJSON via ext/protojsonbinary (Protobuf); ProtoJSON toJson/fromJsonbinary (Protobuf); JSON via toJSON/fromJSONbinary (Avro); Avro JSON via toStringbinary (Binary/Compact) or JSON (TJSONProtocol)binary (FlatBuffers: vtables + offsets)binary (Cap'n Proto, optional packing)binary (Bebop)JSON text (json.*) or binary Protobuf (protobuf.*)binary (custom, big-endian, varints)binary (MessagePack + record extension)binary (MessagePack)binary (CBOR + record tags)binary (BSON)binary (V8 structured clone)Ion text or Ion binary
Human-readable payload✅ JSON arrays✅ JSON✅ JSON✅ JSON✅ JSON, values referenced by index22✅ JSON⚠️ JSON, but base62 numbers and refs❌ binary❌ binary❌ binary⚠️ binary; Avro JSON via type.toString()⚠️ binary; TJSONProtocol is JSON❌ binary❌ binary❌ binary✅ JSON path; protobuf path is binary❌ binary❌ binary❌ binary❌ binary❌ binary (EJSON for text)❌ binary⚠️ Ion text (JSON superset, not JSON)
Travels over a JSON or text channel as is✅ plain JSON✅ it is JSON✅ it is JSON✅ it is JSON✅ JSON string✅ JSON string✅ JSON.stringify(compress(x))❌ binary; base64 to travel as text❌ binary; base64 to travel as text❌ binary; base64 to travel as text✅ only as Avro JSON (toString/fromString)✅ only with TJSONProtocol❌ needs base64❌ needs base64❌ needs base64✅ JSON path; protobuf needs base64❌ Buffer; base64 needed❌ binary, needs base64❌ binary, needs base64❌ binary, needs base64❌ binary; EJSON is a separate JSON form❌ needs base64✅ Ion text is a string
Field names on the wire❌ positional✅ every message✅ every message✅ every message (and in meta paths)✅ every object⚠️ once per collection (header)⚠️ once per distinct key set❌ tag numbers❌ tag numbers❌ tag numbers❌ positional (schema order)❌ tag numbers (field ids)❌ vtable slots (field ids)❌ fixed offsets❌ positional structs; 1-byte tags in messages✅ JSON: every message; protobuf: tag numbers❌ positional (sorted key order)⚠️ once per structure (new Packr() records)✅ every object⚠️ once per structure (new Encoder() records)✅ every document✅ every object, repeated✅ text: each time; binary: once (symbol table)
Decodable without the schema⚠️ parses as JSON; keys need the schema✅ plain JSON✅ plain JSON✅ meta carries the types✅ type tags in the payload✅ keys in the header✅ keys and value kinds encoded⚠️ to raw tags only (Reader)⚠️ to raw tags only (BinaryReader)⚠️ to raw tags only (BinaryReader)❌ needs the writer schema⚠️ to raw tags only (type ids travel)❌ needs the schema⚠️ to raw structs and lists only❌ needs the schema✅ JSON path; protobuf to raw tags only❌ schema required✅ yes, unless shared structures are used✅ yes✅ yes, unless shared structures are used✅ yes (type-tagged elements)✅ tagged values✅ typed values, names in stream
Lazy or zero-copy reads❌ none❌ whole text parsed❌ decode is JSON.parse❌ whole text parsed❌ whole payload revived❌ none❌ none❌ full decode; bytes are views❌ full decode; bytes are views❌ full decode❌ whole record decoded❌ whole struct decoded✅ accessors read the buffer in place✅ lazy accessors❌ decodes the whole record (bytes are views)❌ none❌ none⚠️ only via structon (split out in 2.0)❌ none❌ none⚠️ experimental onDemand.parseToElements❌ whole buffer decoded✅ binary Reader skips values by length
Other languages read the wire⚠️ the wire format is specified; JS only✅ every language (RFC 8259)✅ plain JSON: any language⚠️ a community Elixir port❌ JS only23✅ Python 2, PHP 5, Ruby (in package)✅ Python, PHP, Rust ports24✅ C++, Java, Go, Python, C#, Rust…✅ C++, Java, Go, Python, C#, Rust…✅ C++, Java, Go, Python, C#, Rust…✅ Apache Avro: Java, Python, C, C#, Rust, Go✅ C++, Java, Python, Go, Rust, C#, …✅ C++, Java, Go, Rust, Python, C#, Swift✅ C++, Rust, Go, Java, Python, C#✅ C#, Rust, C++, C, Dart, Python✅ JSON anywhere; protobuf wire via .proto export❌ JS only⚠️ any MessagePack library; records are msgpackr-only✅ MessagePack in C, Go, Python, Rust, Java…⚠️ any CBOR library; records are cbor-x-only✅ every MongoDB driver (C, Java, Go, Python…)❌ V8 only✅ Java, C, Python, Rust, Go, .NET
Types
64-bit integers, BigInt✅ bigint as a decimal string⚠️ doubles; BigInt throws TypeError⚠️ BigInt → JSON number; JSON.parse rounds it✅ BigInt, as a decimal string✅ BigInt (["BigInt","…"])❌ BigInt throws (JSON.stringify)❌ BigInt throws unsupported data type⚠️ Long objects; BigInt only via toObject✅ BigInt (jstype = JS_STRING → string)⚠️ number by default; forceLong=bigint⚠️ numbers; throws past 2^53⚠️ Int64 objects; BigInt with js:node,bigint✅ BigInt (readInt64 returns bigint)✅ BigInt✅ BigInt⚠️ bigint ↔ int64 in protobuf; JSON rejects bigint❌ max int32/uint32; varint ±2³⁰✅ BigInt in and out (±2⁶⁴)⚠️ numbers by default; useBigInt64 opt-in✅ BigInt; bignum tags past 64 bits⚠️ Long/number by default; useBigInt64 opt-in✅ BigInt✅ arbitrary-size ints, bigIntValue()
Dates✅ Date ↔ epoch ms❌ Date → ISO string, comes back a string⚠️ format: 'date-time' → ISO string, string back✅ Date in, Date out✅ Date in, Date out (ISO)❌ ISO string out (via JSON)❌ Date silently becomes {}⚠️ Timestamp { seconds, nanos }, no Date⚠️ Timestamp + timestampFromDate/timestampDate✅ Date via Timestamp (useDate, default)⚠️ logical type you implement (logicalTypes)❌ none❌ no date type; int64 by hand❌ none✅ date ↔ Date (100 ns ticks)⚠️ Date → ISO string; parse returns a string❌ none✅ Date via the timestamp extension (−1)✅ Date via the timestamp extension (−1)✅ Date via tag 1 (epoch)✅ Date via UTC datetime✅ Date in, Date out✅ Date in, dom.Timestamp (a Date) out
Binary data (Uint8Array)✅ Uint8Array ↔ canonical base64❌ Uint8Array → {"0":1,"1":2}❌ Uint8Array in a string → "1,2"✅ typed arrays, as a number array✅ typed arrays, ArrayBuffer (base64 inside)25❌ Uint8Array → {"0":1,…}❌ Uint8Array → {"0":1,…}✅ Uint8Array (Buffer on Node)✅ Uint8Array✅ Uint8Array✅ bytes/fixed as Buffer✅ binary → Buffer✅ [ubyte] → xArray() Uint8Array view✅ Data (toUint8Array() live view)✅ byte[] ↔ Uint8Array (a view)⚠️ Uint8Array in protobuf only⚠️ buffer type, Node Buffer only✅ bin → Buffer/Uint8Array✅ bin → Uint8Array✅ byte string → Uint8Array/Buffer⚠️ Binary wrapper; promoteBuffers for bytes✅ typed arrays, Buffer, ArrayBuffer✅ blob ↔ Uint8Array
Map and Set✅ { map }, { set }❌ Map and Set → {}❌ Map → {}✅ both✅ both❌ become {}❌ silently become {}⚠️ maps as plain objects; no Set⚠️ maps as plain objects; no Set⚠️ Map with useMapType=true; no Set⚠️ map → plain object; no Set⚠️ map → object, set → array❌ neither; vectors only❌ neither⚠️ map[K, V] ↔ Map; no Set⚠️ Map in protobuf only; no Set❌ none✅ both with moreTypes or structuredClone❌ Map and Set encode as {}✅ Map (tag 259) and Set (tag 258)⚠️ Map in, plain object out; no Set✅ both❌ Map and Set dump as {}
Enums✅ as an index— no schema⚠️ enum not enforced; the string travels— no schema— no schema— no schema— no schema✅ number on the wire; names via enums: String✅ number on the wire; name in JSON✅ number on the wire; stringEnums in TS✅ as an index; JS sees the symbol✅ as the i32 number✅ the integer value travels✅ the uint16 ordinal travels✅ the integer value travels✅ literal unions; the string travels❌ none— no schema— no schema— no schema— no schema— no schema⚠️ symbols (no enum type)
Tagged unions✅ discriminated union: [tag, …fields]— any value⚠️ untagged anyOf/oneOf; Ajv picks the branch— any value— any value— any value— any value✅ oneof (virtual property names the case)✅ oneof as { case, value }✅ oneof=unions gives a $case union✅ Avro unions (branch index on wire)✅ union (one field id set)✅ union (type byte + offset)✅ union + which()✅ union → { tag, value }✅ TS union types (protobuf oneof)❌ none— any value— any value— any value— any value— any value— any value; annotations can tag it
Optional fields: absent and null⚠️ optional fields; absent and null both null26✅ absent vs null; undefined dropped⚠️ absent omitted; null → ""/0 unless nullable✅ absent, undefined and null kept apart✅ absent, undefined, null all distinct⚠️ absent becomes null; extra keys dropped✅ absent and null distinct✅ presence with proto3 optional/proto2✅ presence with optional; isFieldSet✅ optional → field?: T | undefined⚠️ ["null", T] union; absent and null merge✅ optional; absent reads as null✅ = null scalars; tables, strings nullable⚠️ scalars: default = absent; pointers _hasX()✅ message fields: absent ≠ set✅ ?: absent vs null distinct❌ every field required✅ absent, null and undefined distinct✅ absent vs null; undefined → null✅ absent, null and undefined distinct✅ absent vs null; undefined dropped✅ absent, undefined and null kept apart✅ absent vs null / typed null.int
Defaults on decode✅ the definition's default— no schema⚠️ default written on encode, not on decode— no schema— no schema— no schema— no schema⚠️ zero values; [default] in proto2/editions only⚠️ zero values; [default] in proto2/editions only⚠️ zero values; proto2 [default] honoured✅ field default (encode and resolver)✅ IDL defaults set in the constructor✅ schema defaults returned when absent✅ schema defaults (XOR-encoded)❌ none (absent → undefined)⚠️ protobuf zero values; Default<V> is schema-only❌ none— no schema— no schema— no schema— no schema— no schema— schemaless
Recursive types, cyclic values✅ references by name, recursion✅ any nesting; cyclic values throw TypeError✅ $ref recursion; cyclic values overflow✅ any nesting; cycles and shared refs✅ any nesting; cyclic and repeated refs✅ any nesting (JSON); no cycles✅ any nesting; no cycles✅ recursive messages, references by name✅ recursive messages, references by name✅ recursive messages, references by name✅ named types by reference; no cyclic values✅ structs reference structs; no cyclic values✅ tables may contain themselves✅ structs may contain themselves✅ messages may contain themselves✅ recursive types; cyclic values rejected❌ none (self-referencing schema overflows build)✅ any nesting; cycles with structuredClone✅ any nesting on decode; no cycles✅ any nesting; cycles with structuredClone✅ any nesting; cycles rejected✅ any nesting; cycles and shared refs✅ any nesting; no cyclic values
Fixed-point decimals✅ decimals: n → a scaled integer❌ doubles only❌ none (multipleOf ignored)❌ none (registerCustom recipe)❌ doubles only❌ none❌ none❌ none (int64 minor units by hand)❌ none (int64 minor units by hand)❌ none (int64 minor units by hand)⚠️ decimal logical type, not implemented❌ none❌ none❌ none❌ none⚠️ MultipleOf<0.01> check; travels as a number❌ none❌ none (float32 rounding options only)❌ none⚠️ tag 4 decoded to a float; not encoded✅ Decimal128❌ none✅ decimal type (ion.Decimal)
Bit flags, packed scalars✅ flags → a bitmask❌ none❌ none❌ none❌ none❌ none❌ none✅ packed repeated scalars (proto3 default)✅ packed repeated scalars (proto3 default)✅ packed repeated scalars (proto3 default)❌ none (a byte per boolean)❌ none✅ (bit_flags) enums✅ Bool fields and List(Bool) bit-packed✅ @flags enums⚠️ packed repeated scalars (protobuf path)❌ a byte per boolean⚠️ typed arrays only with moreTypes⚠️ typed arrays as raw bin, type lost✅ typed arrays (RFC 8746 tags)✅ Binary vectors: int8, float32, packed bits❌ none❌ none
A free-form value in a typed schema✅ json, any fields— schemaless✅ {} schema or additionalProperties: true— schemaless— schemaless— schemaless— schemaless⚠️ Struct as raw fields/Value messages✅ google.protobuf.Struct as JsonObject✅ google.protobuf.Struct as a plain object❌ none (no any type)❌ none⚠️ FlexBuffers bytes in a [ubyte] field⚠️ AnyPointer, cast by hand❌ none⚠️ unknown/any in JSON only (protobuf bans any)❌ none— no schema— no schema— no schema— no schema— schemaless— schemaless
Schema evolution
Add fields✅ append at the end; trailing nulls trimmed✅ no schema✅ keyed JSON: add properties✅ no schema✅ no schema; keys travel✅ no schema; keys in the header✅ no schema; keys travel✅ new tag numbers✅ new tag numbers✅ new tag numbers✅ with default, via createResolver✅ new field ids; old readers skip them✅ append fields or explicit (id: n)✅ new @n ordinals; old data reads defaults✅ messages: new field indices✅ JSON: names travel; protobuf: new Sequence<N>❌ new key shifts sorted positions✅ no schema; keys travel✅ no schema; keys travel✅ no schema; keys travel✅ no schema; keys travel✅ no schema✅ no schema
Rename a field✅ positions travel; aliases in resolve❌ names travel❌ names travel❌ names travel❌ names travel❌ names travel❌ names travel✅ tag numbers travel, not names✅ tag numbers travel, not names✅ tag numbers travel, not names✅ aliases in resolution; names not on wire✅ field ids travel, names do not✅ slots travel, names do not✅ names do not travel✅ indices and positions travel⚠️ protobuf with Sequence<N>; JSON names travel⚠️ positions travel, but sorted by key❌ names travel❌ names travel❌ names travel (keyMap maps names to numbers)❌ names travel❌ names travel❌ names travel
Reader and writer schema resolution✅ resolve(writer, reader) (./resolve)— schemaless❌ none: decode is JSON.parse— schemaless— schemaless— schemaless— schemaless⚠️ tag matching only; no resolver step⚠️ tag matching only; no resolver step⚠️ tag matching only; no resolver step✅ reader.createResolver(writer): aliases, defaults, promotions⚠️ by field id; a mismatched type is skipped❌ none; append-only rules instead❌ none; layout rules instead❌ none❌ none❌ none— no schema— no schema— no schema— no schema— schemaless— schemaless
Schema id or fingerprint✅ schema.id, FNV-1a of the canonical form— schemaless❌ none ($id is a name)— schemaless— schemaless— schemaless— schemaless❌ none❌ none❌ none✅ type.fingerprint(): MD5 of canonical form❌ none in JS⚠️ 4-char file_identifier, set by hand⚠️ 64-bit type ids, stable across versions⚠️ @opcode 4-byte id, by hand❌ none❌ none❌ none for shared structures— no schema❌ none for shared structures— no schema— schemaless— schemaless
Registry or envelope with the schema id✅ SchemaRegistry: [id, payload] (./registry)— schemaless❌ none— schemaless— schemaless— schemaless— schemaless⚠️ Any type URL; Confluent via third party⚠️ Any type URL; Confluent via @confluentinc/schemaregistry⚠️ google.protobuf.Any type URL only⚠️ container files embed schema; registry third-party❌ none (RPC envelope names the method)⚠️ file_identifier in the buffer header❌ segment framing carries no type id❌ none (opcode written by hand)❌ none❌ none (README: add a uint8 field by hand)❌ record ids only; structures kept out of band— no schema❌ record ids only; structures kept out of band— no schema— schemaless⚠️ shared symbol tables by name/version (Catalog)
Unknown fields from a newer writer✅ trailing extras ignored, or rejected with strict— schemaless✅ JSON.parse keeps them; encode drops undeclared— schemaless— schemaless— schemaless— schemaless✅ dropped by default; discardUnknown = false keeps✅ kept in $unknown, re-encoded✅ dropped by default; unknownFields=true keeps⚠️ skipped by the resolver; not kept⚠️ skipped (input.skip), not kept⚠️ ignored; lost on unpack() → pack()✅ kept in the buffer; copyFrom copies them⚠️ ignored: skips to the message end✅ ignored; assertEquals rejects, assertPrune strips⚠️ trailing bytes ignored; no strict mode— no schema— no schema— no schema— no schema— schemaless— schemaless
Decoding untrusted input
Checks the payload on decode✅ the wire checked by default⚠️ syntax only: SyntaxError27❌ no decoder: JSON.parse ignores the schema28❌ bad meta → raw TypeError, Invalid Date29⚠️ indices and tags checked; some raw errors30❌ none; raw errors or garbage31❌ none; undefined or raw errors32✅ bounds, wire types and UTF-8 checked⚠️ bounds and UTF-8 checked; wire type ignored33✅ bounds and wire types checked; UTF-8 not✅ truncated buffer, bad union/enum index⚠️ InputBufferUnderrunError; wrong-type fields skipped34❌ no verifier in JS: garbage or defaults35⚠️ pointer and bounds checks; some raw RangeErrors36❌ no bounds checks: garbage or raw RangeError37✅ json.assertParse, protobuf.assertDecode❌ none; Node RangeErrors or garbage38⚠️ mostly Errors; some raw TypeErrors39✅ DecodeError, or RangeError on truncation⚠️ mostly Errors; some raw TypeErrors40✅ BSONError on every corruption tried⚠️ format only: one generic Error41❌ truncated binary → null or wrong values42
Errors with a path into the payload✅ DecodeError with a path: orders[1].sku⚠️ position in the message⚠️ SyntaxError position only⚠️ SyntaxError position; meta errors none⚠️ message only (Invalid input)❌ raw engine errors❌ raw engine errors⚠️ message with byte offset; verify gives orders.sku⚠️ type and field name, no index⚠️ message only (premature EOF)⚠️ message only (truncated buffer)❌ none; underrun error has no message❌ no decode errors at all⚠️ message with a pointer address, no field path❌ raw errors, no path✅ TypeGuardError.path ($input.a[1].b)❌ raw Node errors⚠️ message only, no path⚠️ message only (byte offsets), no path⚠️ message only, no path⚠️ message only; some name the field❌ no path or offset⚠️ message only (expected ',' or '}')
Deeply nested input✅ stack overflow → DecodeError; maxDepth opt-in⚠️ no limit: 1 000 000 levels parse43⚠️ decode is JSON.parse: no limit44❌ raw RangeError from a deep meta tree45❌ raw stack overflow at ~2,200 levels46✅ not recursive; 100 000 levels decode❌ raw stack overflow at ~3,200 levels47✅ recursionLimit 100: max depth exceeded✅ recursionLimit 100 on fromBinary/fromJson✅ 100 levels on decode, hard-coded❌ raw RangeError at 100 000 levels48✅ 64 levels (DEFAULT_RECURSION_DEPTH)❌ object API unpack(): raw stack overflow49⚠️ lazy reads; deep copy → raw RangeError50❌ raw stack overflow (100 000 levels)❌ recursive generated code: raw stack overflow51— no recursive types; depth fixed by schema❌ raw RangeError at 100 000 levels52✅ iterative decoder: 100 000 levels decode❌ raw RangeError at 100 000 levels53✅ iterative parser: 100 000 levels decode❌ raw RangeError past ~4 150 levels54❌ raw RangeError at 100 000 levels55
__proto__ keys✅ keys from the schema; a dictionary's __proto__ stays own✅ own property, prototype untouched✅ JSON.parse keeps an own key⚠️ own key; a registered class's prototype replaced56✅ rejected (__proto__ key throws)❌ __proto__ header key sets the prototype57❌ __proto__ key sets the prototype58✅ __proto__ map key becomes an own property✅ own property (setOwn) in maps and Struct❌ __proto__ map key sets the prototype59❌ map key __proto__ sets the prototype60✅ keys from the IDL; map __proto__ stays a key✅ keys from the schema✅ keys from the schema✅ keys from the schema❌ protobuf Record key __proto__ sets the prototype61✅ keys from the schema✅ safe: __proto__ renamed to __proto_✅ safe: __proto__ key rejected (DecodeError)✅ safe: __proto__ renamed to __proto_✅ safe: own property via defineProperty✅ own property, prototype untouched⚠️ dom.Struct safe; toJSON() sets the prototype62
Size limits⚠️ maxLength on streams, maxDepth63❌ none (engine string limit only)❌ none (maxDepth is the schema's)❌ none❌ no size or depth options64❌ none❌ none; shared refs expand exponentially65⚠️ depth only (Reader.recursionLimit)66✅ recursionLimit; readMaxBytes 64 MiB on streams⚠️ depth only (100, fixed)❌ none67✅ depth 64; server frame maxLength❌ none (builder capped at 2 GB)⚠️ 64 MiB traversal limit, not configurable68❌ none: 13 bytes → 10^8-element array69❌ none❌ none; declared lengths trusted70⚠️ stream maxIncompleteBufferSize only71✅ maxStrLength, maxBinLength, maxArrayLength, maxMapLength, maxExtLength⚠️ setSizeLimits: array and map sizes (global)72❌ none (size header must match the buffer)❌ none❌ none
Payloads and layouts
Columnar layout for lists✅ layout: 'columns' (and flat)❌ none❌ none❌ none❌ none❌ row-major values after the header❌ none❌ none❌ none❌ none❌ row-wise only❌ row-wise only❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ row-wise only
What an absent optional field costs✅ sparse: a presence mask, nothing per absent field✅ absent keys cost nothing✅ absent keys omitted✅ absent keys omitted✅ nothing (keys omitted)⚠️ null placeholder per absent field✅ nothing (keys omitted)✅ nothing: unset fields are omitted✅ nothing: unset fields are omitted✅ nothing: unset fields are omitted⚠️ a byte each (null union branch)✅ nothing (unset ids omitted)⚠️ 2-byte vtable slot up to last present field⚠️ fixed slots; packing squeezes zeros✅ messages: absent fields cost nothing✅ nothing: absent keys and tags omitted❌ no absent fields✅ nothing (key omitted)✅ nothing (key omitted)✅ nothing (key omitted)✅ nothing (key omitted)✅ absent keys cost nothing✅ nothing (absent fields omitted)
Repeated strings or structures written once✅ intern: a string table per message❌ none❌ none⚠️ dedupe: true writes a shared object once✅ equal values and references written once✅ key header once per collection✅ every value and key list stored once❌ none❌ none❌ none❌ none (strings repeat)❌ none✅ createSharedString; vtables deduplicated❌ none❌ none❌ none❌ none✅ records: keys once per structure❌ none (key cache is decode-side only)✅ records + pack (packed CBOR, tag 51)❌ none⚠️ a shared object written once (back-reference)✅ symbol tables (binary)
Delta encoding✅ delta columns of differences❌ none❌ none❌ none❌ none❌ none❌ none❌ none (zigzag sint is not delta)❌ none (zigzag sint is not delta)❌ none (zigzag sint is not delta)❌ none❌ none (Compact deltas field ids only)❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none
Tooling
Streaming decode✅ NDJSON, TransformStreams, parseItems (./stream)❌ none (NDJSON by hand)❌ none❌ none❌ none (stringifyAsync awaits promises only)❌ none❌ none⚠️ encodeDelimited/decodeDelimited only✅ sizeDelimitedDecodeStream (async iterable)⚠️ useAsyncIterable transforms; no framing✅ BlockDecoder, RawDecoder (Node streams)⚠️ framed/buffered transports (length-prefixed)⚠️ size-prefixed buffers (finishSizePrefixed) only❌ no stream reader (segment framing only)⚠️ length-prefixed messages and unions only❌ none❌ none✅ UnpackrStream, decodeIter (Node streams)✅ decodeMultiStream, decodeArrayStream, decodeAsync✅ DecoderStream, decodeIter, encodeAsAsyncIterable⚠️ deserializeStream over one buffer only❌ none⚠️ Reader steps through values; whole input in memory
Code generated ahead of time✅ toSource: standalone modules (./standalone)— nothing to compile✅ mode: 'standalone' writes a CJS module— nothing to compile— nothing to compile— nothing to compile— nothing to compile✅ pbjs -t static-module + pbts✅ protoc-gen-es: embedded descriptor + types✅ generated .ts per .proto❌ runtime new Function only✅ thrift --gen js:node✅ flatc --ts (--gen-object-api)✅ capnp-es plugin writes .js, .ts, .d.ts✅ bebopc writes TS✅ the ttsc transform inlines codecs at build❌ runtime compile only— nothing to compile— nothing to compile— nothing to compile— nothing to compile— nothing to compile— nothing to compile
CLI✅ protoarray compile❌ none❌ none❌ none❌ none❌ none❌ none✅ pbjs, pbts (protobufjs-cli)✅ protoc-gen-es plugin via buf/protoc✅ protoc-gen-ts_proto plugin via protoc/buf❌ none✅ thrift compiler (native, separate install)✅ flatc (native binary, not on npm)✅ capnp-es (shells out to C++ capnpc)✅ bebopc (npm bebop-tools)✅ ttsc / ttsx (official peer, samchon/ttsc)❌ none❌ none❌ none❌ none❌ none❌ none✅ ion (amazon-ion/ion-cli, Rust)

✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. Every cell was checked against the source of the published package in October 2026.

  1. capnp-es is the maintained fork (unjs) of capnp-ts; its README calls it alpha quality. capnp-ts itself was revived in 2026 (0.9.3).
  2. The browser builds in etc/browser/ still require('buffer'), util and stream; the README assumes browserify. It cannot run where new Function is blocked (see noEval).
  3. The browser field maps to lib/nodejs/lib/thrift/browser.js, which still requires util (thrift.js) and uses Buffer in every protocol; the README suggests webpack.
  4. require('buffer').Buffer (schemapack.js:6); the README relies on browserify/webpack 4 adding the shim, which webpack 5 no longer does.
  5. index.js:470 compiles every serializer (EvalError in a realm without code generation). A mode: 'standalone' module runs there, but anyOf/oneOf/if also need inlineValidators: true or Ajv compiles at runtime.
  6. Type.encode/decode/verify and the message constructor are compiled by util.codegen (src/util/codegen.js:53,55, Function(source)); with --disallow-code-generation-from-strings reflection throws EvalError, while a pbjs -t static-module file on protobufjs/minimal runs.
  7. lib/types.js:732 (every type's branch constructor), 2200, 2275, 2295, 2310, 2342, 2434 (record constructor, checker, reader, skipper, writer, resolver). Under node --disallow-code-generation-from-strings even require('avsc') throws EvalError (lib/containers.js:26 builds a type at load). No no-eval mode exists.
  8. schemapack.js:375-376; under --disallow-code-generation-from-strings build throws EvalError (tested).
  9. From the 4th read of a record structure the reader is compiled with new Function (unpack.js:501), also by the default unpack on a payload that defines records in-message. A throw is caught and code generation is switched off for the process (still a CSP violation report). msgpackr/index-no-eval (a UMD build, dist/index-no-eval.cjs) never calls it.
  10. decode.js:47 runs new Function('') when the module loads (a CSP report even if records are never used) and compiles record readers from the 4th read (decode.js:532). If the probe throws, records use the slow path. cbor-x/index-no-eval never calls it.
  11. The README's security notice: schemas are compiled into code, so a schema from a peer is not safe to build.
  12. protoarray's encode trusts its values and checks only what the wire is built from (shapes, a union's discriminator, a list of differences); validating the values is metaschema's, on the same definition, so its cost is opt-in.
  13. Only required and array shapes throw. pattern, minimum, enum and format are ignored, 'false' in a boolean → true, and a const field writes the constant whatever the value.
  14. toBinary throws on "abc", 3.7 or 2^40 in an int32 (cannot encode field t.Msg.age to binary: invalid int32: 3.7) but writes a number in a string field as "42"; create() checks nothing; reflect().set type-checks; rule validation is the separate @bufbuild/protovalidate.
  15. encode throws on "abc" or 3.7 in an int32 (invalid int32: NaN) but writes a number in a string field as "42"; fromJSON coerces (age: "abc" → NaN).
  16. Generated write() calls output.writeI32(this.x) unchecked (binary.js writeI32 uses bit operations; 1.7 → 1). Required fields are checked only by the constructor when args is given; writeString(42) throws.
  17. Tested: "abc" in a short reads back 0, 70000 reads back 4464, 1.9 reads 1. Only (required) presence is checked, by builder.requiredField in the generated endX().
  18. Tested: an object in a Text field is stored as "[object Object]", null as "null"; an Int64 setter throws a raw TypeError for a number.
  19. Tested on the generated-code shape: a missing struct field writes 0, a number in a string field writes "", null throws a raw TypeError. The dynamic RecordWriter checks shapes, enums and missing fields, not scalar types.
  20. build(schema, false) drops them: a string in a uint8 field then encodes as 0. With checks on, 3.7 in an int field becomes 3, NaN passes float checks, a missing string throws Node's ERR_INVALID_ARG_TYPE.
  21. uneval writes JavaScript source text instead. The README lists stability of the format between devalue versions as a non-goal.
  22. README lists human-readable output as a non-goal: {a:'x'} travels as [{"a":1},"x"].
  23. No port found; the format is not specified and may change between versions (README non-goals).
  24. Python in the same repo (PyPI compress-json-python); PHP and Rust are third-party (README).
  25. Ordinary typed arrays serialize their whole backing ArrayBuffer, bytes outside a subarray included (README); Node Buffers are copied first (src/operations.js:83).
  26. undefined, null and a missing key travel as null and decode as null (or the default): every key of the schema is present after decode.
  27. Any well-formed value comes back; checking its shape is left to the program.
  28. Validate with Ajv (already a dependency) against the same schema.
  29. 5 annotated map throws TypeError: number 5 is not iterable; a garbage Date string becomes Invalid Date; the text null throws a destructuring TypeError.
  30. Bad indices and unknown tags throw Error('Invalid input')/Unknown type, but ["BigInt","abc"], ["RegExp","("], ["URL","x"] throw raw SyntaxError/TypeError, Temporal.* tags throw ReferenceError on Node 24, and ["Date",5] is accepted as a Date.
  31. A row missing a value throws RangeError: Invalid array length; unpack('abc') returns [], a string key count returns [ <1 empty item> ], null throws a TypeError.
  32. Out-of-range or invalid refs decode to undefined silently, a self-reference overflows the stack, decompress(null) throws c is not iterable.
  33. Truncation throws RangeError: premature EOF; but a known scalar field is read by its schema type whatever wire type arrives (dist/esm/from-binary.js:168-183): an int32 writer's 08 02 10 03 decodes as name: "\u0010\u0003", age: 0, silently.
  34. Truncation, negative sizes and bad type ids (Invalid type: 127) are errors; a missing required field is not (it reads as null).
  35. Tested: a buffer cut in half decodes to { hp: 0, name: "" }, an out-of-bounds root offset to all defaults, no error. ByteBuffer reads bytes_[i] without bounds checks (ts/byte-buffer.ts); the C++ Verifier is not ported.
  36. Library errors (CAPNP-TS019, TS024) for bad pointers, but an empty buffer and a huge Text length throw raw RangeErrors, and the frame header is trusted before its size check (see limits).
  37. Tested: a truncated buffer throws DataView RangeError; a string length past the end decodes to "A\u0000".
  38. A truncated buffer throws Node's ERR_OUT_OF_RANGE or silently shortens the last string; a buffer field whose length prefix exceeds the payload returns stale process memory (allocUnsafe, schemapack.js:86; tested).
  39. Truncation gives "Unexpected end of MessagePack data". In 3 000 random byte mutations 1–5% escaped as raw TypeError/RangeError/SyntaxError (e.g. read(...).map is not a function, a pointer ext without a reference map); the reserved byte 0xC1 decodes to a C1 sentinel object instead of an error.
  40. Truncation gives "Unexpected end of CBOR data". In 3 000 random byte mutations 5–7% escaped as raw TypeError/RangeError (e.g. "Cannot create property 'slowReads' on string", "byte length of Float32Array should be a multiple of 4"); a record key that is a BigInt reaches JSON.stringify and throws a TypeError.
  41. Truncated, garbage and huge-length inputs all throw Unable to deserialize cloned data.; there is no shape to check.
  42. IonSpan.js:165-170 returns EOF (-1) past the end instead of throwing: a 55-byte value cut to 13-27 bytes loads as null, cut to 52 bytes loads with a different timestamp. The text parser rejects truncated input.
  43. V8's JSON.parse does not recurse, but JSON.stringify of the result throws RangeError past ~6 200 levels, as may any recursive consumer.
  44. maxDepth (default 100) limits the schema, not data; encoding a 100 000-deep recursive value throws a raw RangeError.
  45. Deep json alone parses (JSON.parse); a 100 000-deep meta.values tree overflows the recursive traverse (dist/plainer.js:8).
  46. hydrate (src/parse.js:71) recurses: a 14 KB flat payload of ~2,200 nested arrays (Node 24 default stack) throws a raw RangeError: Maximum call stack size exceeded; stringify and uneval overflow too on deep values.
  47. decode ↔ decodeArray/decodeObject recurse (dist/core.js:16-51); a chain of ~3,200 nested arrays throws a raw RangeError on Node 24.
  48. A 100 001-byte recursive list throws Maximum call stack size exceeded from fromBuffer; toBuffer/isValid of a deep or cyclic value overflow the same way.
  49. Accessors are lazy and do not recurse, but generated unpack() does: a 100 000-deep chain throws RangeError, and a crafted negative uoffset (accepted: readInt32 is signed) makes a cycle that overflows too.
  50. Reads do not recurse and the traversal limit stops a pointer cycle (CAPNP-TS022), but the nesting limit defaults to MAX_INT32: setRoot/copyFrom of a 100 000-deep message overflows the stack.
  51. Not run through the transform (it needs ttsc): a decoder transcribed from ProtobufDecodeProgrammer.go overflows at 100 000 levels; no depth option exists.
  52. Recursive read() with no depth limit. checkedRead marks any RangeError incomplete (unpack.js:225), so UnpackrStream treats a deep message as a partial one: it emits nothing, buffers, and stalls until maxIncompleteBufferSize (64 MB) is exceeded. pack of a deep value also overflows.
  53. Recursive read() with no depth limit. checkedRead marks any RangeError incomplete (decode.js:239), so DecoderStream treats a deep message as a partial one: it emits nothing and keeps buffering, with no size cap. encode of a deep value also overflows.
  54. Measured on a hand-built nested array with the default stack; v8.serialize itself throws RangeError past ~3 280 levels.
  55. load of 100 000 nested lists or s-expressions, and dumpText/dumpBinary of a deep or cyclic value, throw Maximum call stack size exceeded. The raw Reader cursor steps in 100 000 levels fine.
  56. Plain objects are safe and meta paths through proto are rejected, but a value typed as a registered class is built with Object.assign (dist/transformer.js:138), whose proto setter replaces the instance's prototype.
  57. o[hlist[++ki]] = hlist[i++] on a literal {} (js/jsonh.js:64): [1,"proto",{"isAdmin":true}] decodes to a record that inherits isAdmin (tested).
  58. o[k] = v on a literal {} (dist/core.js:33): a payload with key proto yields an object that inherits the attacker's properties (tested).
  59. Binary decode assigns map entries with obj[key] = value (build/src/main.js:1223): a map<string, Item> entry keyed proto makes the decoded map inherit from the attacker's message; Struct keys hit the same path (with useMapType the decoded Struct object's prototype is replaced). fromJSON uses defineProperty and is safe.
  60. lib/types.js:1833-1838 assigns val[key] on {}: the key disappears and the decoded map inherits the payload's object (same in fromString). No global pollution.
  61. Generated decode assigns output[entry.key] = entry.value on {} (native/core/programmers/protobuf/ProtobufDecodeProgrammer.go:379-397); reproduced with the real _ProtobufReader. The JSON path parses with JSON.parse and is safe.
  62. Fields live in Object.create(null) (dom/Struct.js:10), so get('proto') works and nothing global is touched. toJSON() copies them onto Object.create(Struct.prototype) with obj[key] =: proto replaces the prototype and the fields after it vanish (JSON.stringify gives {}).
  63. parse reads a whole text with JSON.parse, so the text a program accepts is its own limit; the stream parsers hold at most maxLength characters of one item (16 MiB by default).
  64. A sparse array's declared length is not allocated (src/operations.js:181), so [[-7,4294967295]] is cheap.
  65. A 171-byte payload of arrays that each reference the previous one twice decompresses to 6.3 MB of JSON (20 levels); each level doubles it.
  66. No message, string or array size limits.
  67. A 4-byte payload decodes into an array of 1 000 000 nulls (array<null> with a count only).
  68. It counts pointer objects per Message, so one legit Message re-read ~4.2 M times throws. The segment count in the frame header is allocated (Array.from, BlZmb8ia.mjs:818) before it is checked: 8 bytes claiming 2^32-1 segments start a multi-billion-element allocation.
  69. A bool[] length prefix is trusted: 13 bytes decoded into a 100 000 000-element array in 1.2 s (up to 2^32 possible).
  70. A 4-byte payload for a buffer field yields a 268 MB Buffer.
  71. Array/map lengths are checked against the remaining bytes (no allocation from a small header), but there is no option for string, array, map or depth limits.
  72. Process-wide, not per decoder; defaults 112 810 000 / 16 810 000. maxObjectSize, documented in the README, is never read by the decoder, record objects bypass maxMapSize, and there is no string or depth limit.

Released under the MIT License.