skip to content

In TypeScript, how do you write a single type that describes any value `JSON.parse` can return, including arbitrarily deep nesting, and why is a type alias allowed to name itself?

level: juniorimportance: should knowfreq 42%

answer

  1. a type may name itself
  2. structure between the name and itself
  3. union of primitives plus containers
  4. array branch and index-signature branch
  5. parse returns any, nothing is checked

basics

~20 s

Use a recursive type alias: a JSON value is a string, number, boolean, null, an array of JSON values, or an object whose values are JSON values. The self-reference is legal because TypeScript resolves it lazily instead of expanding it eagerly.

solid answer

~50 s

I write `type Json = string | number | boolean | null | Json[] | { [key: string]: Json }`. That alias refers to itself in two of its union members, which TypeScript accepts because the reference sits inside an array element or an index signature — the compiler defers resolving it until the type is actually used, so there is nothing infinite to expand. What it rejects is a *directly* circular alias like `type A = A` or `type A = A | string`, where resolving the alias immediately requires the alias itself. The same laziness is why `interface TreeNode { value: string; children: TreeNode[] }` works. One caveat worth saying out loud: `JSON.parse` is declared to return `any`, so annotating the result as `Json` is an assertion about data you have not checked, not a validation of it.

go deeper

for a junior

Be able to write the JSON union from memory and say why an alias may mention itself inside an array or property. Know that arrays must be tested with Array.isArray before the typeof object check.

for a middle

Explain lazy resolution: the compiler defers the self-reference instead of expanding it, so only a directly circular alias is rejected. Show the same pattern applied to a tree or a generic Tree<T>.

for a senior

Point out that JSON.parse is declared as any, so the annotation is an unchecked assertion, and describe when you would instead parse into unknown and validate. Discuss readonly variants for shared configuration.

for a principal

Own the boundary policy: which layer is allowed to hold an open Json type, where every external payload must pass through validation, and how you keep that rule from eroding as teams reach for a quick annotation instead.

## What a recursive type is A type is recursive when its definition mentions itself. TypeScript allows this for type aliases and for interfaces, and it is the only way to describe data whose depth is not known in advance: JSON documents, tree and menu structures, comment threads, AST nodes, nested configuration. The canonical example is the JSON value type: ```typescript type Json = | string | number | boolean | null | Json[] | { [key: string]: Json }; ``` Read it as a grammar rather than as a shape: a JSON value is one of four primitives, or a list of JSON values, or a string-keyed bag of JSON values. Any legal JSON document — nested to any depth — matches. ## Why the self-reference compiles The compiler does not substitute an alias's body everywhere the alias appears; it stores the alias and resolves the reference **lazily**, when something actually asks a question about the type. Inside `Json[]` and inside `{ [key: string]: Json }`, the self-reference is *deferred*: to know that `Json[]` is an array you never need to have finished computing `Json`. The rejected case is a reference that must be resolved to make any progress at all: ```typescript type A = A; // error: type alias circularly references itself type B = B | string; // same problem — the union cannot be built type C = C[]; // fine — the reference sits under an array type D = { next: D }; // fine — the reference sits under a property ``` So the rule of thumb is: a recursive alias needs at least one layer of *structure* (object property, array element, function parameter or return) between the name and itself. Interfaces get this for free, because an interface's members are always resolved on demand — `interface TreeNode { children: TreeNode[] }` never trips the check. ## Using the type Annotating a literal is the easy half: ```typescript const config: Json = { name: "app", retries: 3, flags: { beta: true, tags: ["a", "b"] }, fallback: null, }; ``` Consuming one requires narrowing, because `Json` is a union and the checker will not let you index into it or call methods on it until you have proved which branch you hold: ```typescript function depthOf(value: Json): number { if (Array.isArray(value)) { return 1 + Math.max(0, ...value.map(depthOf)); } if (value !== null && typeof value === "object") { return 1 + Math.max(0, ...Object.values(value).map(depthOf)); } return 0; } ``` Note the order: the `typeof value === "object"` test must exclude `null` explicitly, and the array test comes first because arrays are objects. Those checks are ordinary control-flow narrowing; the recursion in the *type* and the recursion in the *function* mirror each other, which is what makes the type pleasant to work with. ## The erasure caveat Everything above is compile-time only. The standard library declares `JSON.parse` as returning `any`, so this: ```typescript const data: Json = JSON.parse(input); ``` compiles no matter what `input` contains. Nothing inspects the parsed value at runtime; you have simply told the checker to treat an unchecked value as a `Json`. If the payload came from a network or a user, the honest options are to parse into `unknown` and narrow it yourself, or to validate with a runtime schema library and let the validated result carry the type. The type describes the shape you *intend*; it does not enforce it. ## Variations worth knowing - `Record<string, Json>` is equivalent to the index-signature member and reads a little better in some codebases. - `undefined` is deliberately absent: `JSON.stringify` drops `undefined` properties, so including it in a JSON type models something the format cannot represent. - Making the object branch `{ readonly [key: string]: Json }` and the array branch `readonly Json[]` gives an immutable variant, useful for frozen configuration. - A generic tree is written the same way: `type Tree<T> = { value: T; children: Tree<T>[] }`. The type parameter threads through the recursion unchanged. Recursive aliases only become a problem when the recursion drives *computation* — conditional types that rebuild a type step by step can exhaust the compiler's instantiation budget. A plain data-shape recursion like `Json` performs no computation and costs essentially nothing.

  • Why does `type A = A | string` fail while `type A = { next: A } | string` compiles?
    In the first, building the union requires the union's own resolved members, so the compiler cannot make progress and reports a circular self-reference. In the second, the self-reference sits behind an object property, which is resolved lazily — the union can be formed immediately and `A` is only unfolded when something actually inspects `next`.
  • Would you type a parsed API response as `Json` in production code?
    Rarely. `Json` says "some JSON-ish value", which forces narrowing at every access and still proves nothing about the payload. I would parse into `unknown` and validate with a schema, letting the validator's inferred type describe the response. `Json` is right for genuinely open-ended data — a config blob, a stored document, a passthrough field.
  • How would you make the JSON type reject mutation of nested values?
    Add the modifiers on both container branches: `type ReadonlyJson = string | number | boolean | null | readonly ReadonlyJson[] | { readonly [key: string]: ReadonlyJson }`. Because the alias is recursive, the modifier applies at every depth automatically. It is still compile-time only — nothing freezes the object at runtime.

saying these in an interview costs you the question

  • Claims TypeScript forbids any self-referencing type
  • Types the payload as any and calls it done
  • Thinks annotating JSON.parse validates the data
  • Writes type A = A and expects it to resolve
  • Says recursion here needs a base case in the type

context