11 KiB
Spec: @suaveplan/json
Status: shipped at 0.1.0
Package path: primitives/json
Landed: 2026-04-20
Spec heading conformance: json-spec-conformance (2026-04-26)
Capability
A safe JSON parse/stringify surface for the Suaveplan monorepo. Wraps the platform JSON global with prototype-pollution defense, AbortSignal handling, structured JsonError codes, OpenTelemetry spans, and a tryParse / isValidJSON / getStringifySize ergonomic layer that the platform global does not provide.
Requirements
Requirement: Safe JSON parsing with structured errors
The package SHALL export safeParse<T>(text, options?) that returns the parsed value on success and throws JsonError with code JSON.PARSE.FAILED on invalid syntax. It MUST accept an AbortSignal; if already aborted before parsing begins, it MUST throw JsonError(JSON.PARSE.FAILED).
Scenario: Valid JSON parses successfully
- WHEN
safeParseis called with valid JSON (object, array, null, string, number, boolean, nested object) - THEN the value is returned with the expected shape
Scenario: Invalid JSON throws JsonError
- WHEN
safeParseis called with malformed text - THEN a
JsonErrorwith codeJSON.PARSE.FAILEDis thrown
Scenario: Already-aborted signal aborts before parsing
- WHEN
safeParseis called with anAbortSignalthat is already aborted - THEN a
JsonErroris thrown without parsing the input
Verified by: src/safe-json/safe-json.test.ts::"parses valid JSON" AND src/safe-json/safe-json.test.ts::"parses a JSON array" AND src/safe-json/safe-json.test.ts::"parses null literal" AND src/safe-json/safe-json.test.ts::"parses string literal" AND src/safe-json/safe-json.test.ts::"parses number literal" AND src/safe-json/safe-json.test.ts::"parses boolean literals" AND src/safe-json/safe-json.test.ts::"parses nested objects successfully" AND src/safe-json/safe-json.test.ts::"throws on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.FAILED code on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws when AbortSignal is already aborted"
Requirement: Prototype-pollution defense via forbidden-key list
safeParse SHALL reject keys in the forbiddenKeys list (default: __proto__, constructor, prototype) by throwing JsonError with code JSON.PARSE.PROTOTYPE_POLLUTION. Callers MAY override the list (including the empty list to disable the check, or a custom set).
Scenario: Default forbidden keys are rejected
- WHEN
safeParseis called with JSON containing__proto__,constructor, orprototype - THEN a
JsonErrorwith codeJSON.PARSE.PROTOTYPE_POLLUTIONis thrown
Scenario: Forbidden-keys list is configurable
-
WHEN
safeParseis called with{ forbiddenKeys: [] } -
THEN
__proto__parses without throwing -
WHEN
safeParseis called with{ forbiddenKeys: ["secret"] }and JSON containingsecret -
THEN a
JsonErrorwith codeJSON.PARSE.PROTOTYPE_POLLUTIONis thrown
Verified by: src/safe-json/safe-json.test.ts::"rejects __proto__ key" AND src/safe-json/safe-json.test.ts::"rejects constructor key" AND src/safe-json/safe-json.test.ts::"rejects prototype key" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys" AND src/safe-json/safe-json.test.ts::"allows custom forbidden keys — empty list allows __proto__" AND src/safe-json/safe-json.test.ts::"allows custom forbidden keys — custom key blocks the key"
Requirement: Safe JSON stringification with structured errors
The package SHALL export safeStringify(value, options?) returning a JSON string. It MUST throw JsonError with code JSON.STRINGIFY.CIRCULAR on circular references, and JSON.STRINGIFY.FAILED for any non-circular exception (e.g. BigInt) thrown by the underlying JSON.stringify. DAG sharing (the same object reachable via multiple non-cyclic paths) MUST be allowed without throwing. It MUST accept an AbortSignal; if already aborted before stringification begins, it MUST throw JsonError(JSON.STRINGIFY.FAILED).
Scenario: Plain values serialise as JSON
- WHEN
safeStringifyis called with a plain object, array, primitive, ornull - THEN the output is the standard JSON representation, optionally indented when
spaceis provided
Scenario: Circular references throw STRINGIFY.CIRCULAR
- WHEN
safeStringifyis called with a value that has a cycle through a property - THEN a
JsonErrorwith codeJSON.STRINGIFY.CIRCULARis thrown
Scenario: DAG sharing is permitted
- WHEN the same non-cyclic object is referenced at multiple paths
- THEN stringification succeeds
Scenario: Non-serialisable values throw STRINGIFY.FAILED
- WHEN
safeStringifyis called with aBigInt(or any valueJSON.stringifyrejects non-cyclically) - THEN a
JsonErrorwith codeJSON.STRINGIFY.FAILEDis thrown
Scenario: Already-aborted signal aborts before stringifying
- WHEN
safeStringifyis called with an abortedAbortSignal - THEN a
JsonErroris thrown without stringifying
Verified by: src/safe-json/safe-json.test.ts::"serialises a plain object" AND src/safe-json/safe-json.test.ts::"serialises with space option" AND src/safe-json/safe-json.test.ts::"serialises arrays" AND src/safe-json/safe-json.test.ts::"serialises null" AND src/safe-json/safe-json.test.ts::"serialises primitives" AND src/safe-json/safe-json.test.ts::"throws on circular reference" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.CIRCULAR code on circular reference" AND src/safe-json/safe-json.test.ts::"throws when AbortSignal is already aborted" AND src/safe-json/safe-json.test.ts::"handles nested objects without circular refs" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)" AND src/safe-json/safe-json.test.ts::"allows same object reference at different paths (not a cycle)"
Requirement: Non-throwing parse helpers
The package SHALL export tryParse<T>(text, options?) that returns undefined instead of throwing on any failure (including prototype-pollution rejections), and isValidJSON(text) returning true for any text that parses (including the literal "null", strings, numbers, booleans, arrays — i.e. NOT a naïve tryParse(text) !== undefined check).
Scenario: tryParse swallows errors
- WHEN
tryParseis called with invalid JSON or with prototype-polluting JSON under default forbidden keys - THEN the return value is
undefined
Scenario: tryParse passes options through to safeParse
- WHEN
tryParse(text, options)is called - THEN the call delegates to
safeParse(text, options)so the same options apply
Scenario: isValidJSON treats "null" as valid
- WHEN
isValidJSON("null")is called - THEN the result is
true
Scenario: isValidJSON covers every JSON shape
- WHEN
isValidJSONis called with valid JSON of any shape (object, array, string, number, boolean, null) - THEN the result is
true
Scenario: isValidJSON rejects malformed and prototype-polluting input
- WHEN
isValidJSONis called with invalid text, the empty string, or a JSON document containing forbidden keys - THEN the result is
false
Verified by: src/safe-json/safe-json.test.ts::"returns undefined on invalid JSON" AND src/safe-json/safe-json.test.ts::"returns parsed value on valid JSON" AND src/safe-json/safe-json.test.ts::"returns null for JSON null" AND src/safe-json/safe-json.test.ts::"returns undefined when safeParse throws for prototype pollution" AND src/safe-json/safe-json.test.ts::"returns parsed object" AND src/safe-json/safe-json.test.ts::"passes options through to safeParse" AND src/safe-json/safe-json.test.ts::"returns true for valid JSON object" AND src/safe-json/safe-json.test.ts::"returns false for invalid JSON" AND src/safe-json/safe-json.test.ts::"returns true for JSON null literal" AND src/safe-json/safe-json.test.ts::"returns true for JSON string" AND src/safe-json/safe-json.test.ts::"returns true for JSON number" AND src/safe-json/safe-json.test.ts::"returns true for JSON boolean" AND src/safe-json/safe-json.test.ts::"returns true for JSON array" AND src/safe-json/safe-json.test.ts::"returns false for empty string" AND src/safe-json/safe-json.test.ts::"returns false for prototype-polluting JSON (forbidden key)"
Requirement: Stringify byte-size estimator
The package SHALL export getStringifySize(value) returning the UTF-8 byte length of JSON.stringify(value).
Scenario: Returns byte length of serialised value
- WHEN
getStringifySizeis called with any JSON-serialisable value - THEN the result equals
utf8ByteLength(JSON.stringify(value))
Scenario: Multi-byte characters count correctly
- WHEN
getStringifySizeis called with a string containing non-ASCII characters - THEN the result counts each character's UTF-8 byte width
Verified by: src/safe-json/safe-json.test.ts::"returns byte size of serialised value" AND src/safe-json/safe-json.test.ts::"returns correct size for string with multi-byte chars" AND src/safe-json/safe-json.test.ts::"returns correct size for simple number"
Requirement: Round-trip identity
safeStringify followed by safeParse SHALL be the identity on any JSON-serialisable value (modulo Date/undefined JSON limitations baked into the platform global).
Scenario: Round-trip preserves arbitrary JSON-serialisable values
- WHEN any JSON-serialisable value is passed through
safeStringifythensafeParse - THEN the output is structurally equal to the input
Verified by: src/safe-json/safe-json.test.ts::"safeParse round-trips any JSON-serialisable value" AND src/safe-json/safe-json.test.ts::"safeStringify + safeParse is identity for clean objects"
Requirement: OTel spans and stable error-code namespace
safeParse and safeStringify SHALL emit OpenTelemetry spans via @opentelemetry/api. All error codes MUST be registered under the JSON.* namespace via defineCodes from @suaveplan/error.
Scenario: Errors carry the JSON.* code namespace
- WHEN any
JsonErroris thrown - THEN the
codefield begins withJSON.(e.g.JSON.PARSE.FAILED,JSON.PARSE.PROTOTYPE_POLLUTION,JSON.STRINGIFY.CIRCULAR,JSON.STRINGIFY.FAILED)
Verified by: src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.FAILED code on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.CIRCULAR code on circular reference" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)"
Error codes
JSON.PARSE.FAILED, JSON.PARSE.PROTOTYPE_POLLUTION, JSON.STRINGIFY.CIRCULAR, JSON.STRINGIFY.FAILED
Quality gates
- Istanbul coverage on all 4 thresholds: 100%.
- Docs coverage: 100%.