10LOC
TypeScriptintermediate

A recursive type for JSON-safe values

Published March 16, 2027

type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

const isJsonValue = (value: unknown): value is JsonValue => {
  if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
  if (Array.isArray(value)) return value.every(isJsonValue);
  if (typeof value === "object") return Object.values(value).every(isJsonValue);
  return false;
};

export const toJsonValue = (input: unknown): JsonValue => {
  if (!isJsonValue(input)) throw new TypeError("Value is not JSON-safe");
  return input;
};

What

JsonValue is the type of anything JSON.stringify can faithfully serialize and JSON.parse can faithfully reconstruct: primitives, null, arrays of JsonValue, or string-keyed objects of JsonValue — referencing itself in its own definition. isJsonValue is the runtime check that actually walks a value to confirm it fits.

Why it matters

Plenty of values pass through JSON.stringify without a runtime error and still come back wrong: undefined properties are silently dropped, Date objects turn into strings and don't turn back into Dates on the way in, functions and symbols vanish, and NaN/Infinity become null. None of that raises an exception — it's silent data loss that shows up as a bug much later, often in whatever consumes the parsed result. A JsonValue type lets you say "this data must survive a JSON round-trip" once, in a type signature, and have anything that doesn't fit — a Date, a class instance, undefined — rejected wherever it would be passed in.

How it works

  • JsonValue's own definition mentions JsonValue twice, in the array and object branches — that's what makes it recursive. TypeScript can resolve this because it's lazy about expanding type aliases; it only recurses as deep as an actual value does.
  • isJsonValue's primitive check (string/number/boolean/null) is the recursion's base case; arrays and objects each recurse by calling isJsonValue again on every element or value.
  • Array.isArray is checked before the generic typeof value === "object" branch, since arrays are also "object" under typeof — checking the object branch first would treat every array as a "string-keyed object" instead.
  • toJsonValue is the safe boundary function: anything that gets past it is guaranteed, both by the type and by the runtime check that produced it, to be JSON-safe.

Gotchas

  • This type says nothing about undefined, Date, class instances, or bigint — they're simply not part of the union, so a value containing one fails isJsonValue. That's the intended behavior, but it does mean a legitimate Date field needs to be converted to an ISO string (a JsonValue) before it'll pass.
  • Object key order and NaN/Infinity aren't type-level concerns here at all — JsonValue describes shape, not every JSON-serialization quirk.

Related

  • unknown + a type guard — the same narrowing mechanism (isJsonValue is exactly that pattern), applied here to a recursively-defined type instead of a flat object shape.
  • DeepPartial<T> — another recursive type, but one that transforms an existing type's structure rather than defining a self-contained value type from scratch.