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 mentionsJsonValuetwice, 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 callingisJsonValueagain on every element or value.Array.isArrayis checked before the generictypeof value === "object"branch, since arrays are also"object"undertypeof— checking the object branch first would treat every array as a "string-keyed object" instead.toJsonValueis 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, orbigint— they're simply not part of the union, so a value containing one failsisJsonValue. That's the intended behavior, but it does mean a legitimateDatefield needs to be converted to an ISO string (aJsonValue) before it'll pass. - Object key order and
NaN/Infinityaren't type-level concerns here at all —JsonValuedescribes shape, not every JSON-serialization quirk.
Related
unknown+ a type guard — the same narrowing mechanism (isJsonValueis 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.