Recursive Types in TypeScript: JSON, DeepReadonly and DeepPartial
We cover self-referencing types, a recursive JSON type, DeepReadonly and DeepPartial with their function and array pitfalls, recursive conditional types and the TS2589 limit with tail recursion.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We wrote a type that describes every value that can be turned into JSON. Notice that the type uses itself inside its own definition:
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
const profile: Json = { name: "Ada", tags: ["admin"], meta: { active: true } };
const entry: Json = { createdAt: new Date() };
const options: Json = { retry: undefined };
Which of the three assignments compile? If some fail, what does the compiler object to? If you remember from the JavaScript series what JSON.stringify does with Date and undefined, that's your hint.
Types That Refer to Themselves
A recursive type is a type that refers to itself inside its definition. The most familiar examples are tree structures: a folder's subfolders, a comment's replies, a menu's submenus. Interfaces could do this from the very beginning:
interface TreeNode {
value: number;
children: TreeNode[];
}
function sum(node: TreeNode): number {
return node.children.reduce((total, child) => total + sum(child), node.value);
}
const tree: TreeNode = {
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] },
],
};
console.log(sum(tree)); // 10
A recursive type usually means the function that processes it is recursive too. sum adds each node's own value to the total of its children; a node with no children is a natural stopping point.
Type aliases, on the other hand, were limited here until TypeScript 3.7. Since that version an alias can refer to itself from behind a structure (an object property, an array, a tuple or a conditional type branch). If there is no structure in between, the compiler spots the infinite loop right away:
type Nested = string | Nested[]; // fine: the reference sits behind an array
// Error: Type alias 'Loop' circularly references itself.
type Loop = Loop | string;
To unfold Nested you first have to see an array, so the compiler unfolds it one more level only when needed. Trying to unfold Loop, however, leads straight back to Loop and gives TS2456.
The JSON Type
Back to the prediction. profile compiles without trouble: it has strings, a string array and a nested object, all of which fit a member of Json. The other two fail. For entry, the compiler's explanation starts like this:
error TS2322: Type '{ createdAt: Date; }' is not assignable to type 'Json'.
Types of property 'createdAt' are incompatible.
Type 'Date' is not assignable to type 'Json | undefined'.
Type 'Date' is not assignable to type '{ [key: string]: Json; }'.
Index signature for type 'string' is missing in type 'Date'.
Date is an object, but the object member of Json requires an index signature whose every property is again Json, and Date doesn't fit that shape. This matches real life too: JSON.stringify turns a date into a string, and when you read it back you hold a string, not a Date. options is rejected with TS2322 because undefined doesn't exist in JSON; JSON.stringify silently drops such a property.
A function that processes a recursive type follows the type's shape too. Let's count all the leaves (non-nested values) in a JSON value:
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
function countLeaves(value: Json): number {
if (value === null || typeof value !== "object") {
return 1;
}
const children = Array.isArray(value) ? value : Object.values(value);
let total = 0;
for (const child of children) {
total += countLeaves(child);
}
return total;
}
const config: Json = JSON.parse('{"name":"Ada","tags":["admin","dev"],"meta":{"active":true}}');
console.log(countLeaves(config)); // 4
A warning: JSON.parse returns any, so assigning it to Json checks nothing. As we discussed in the Intermediate series, validating data from outside at runtime is still your job; the Json type only describes the shape of what you hold after validation.
DeepReadonly
In the mapped types post we saw that Readonly is shallow and doesn't reach nested objects. With a recursive mapped type we can freeze every level. Here is a first attempt:
type DeepReadonlyNaive<T> = { readonly [K in keyof T]: DeepReadonlyNaive<T[K]> };
declare const handler: DeepReadonlyNaive<() => void>;
// Error: This expression is not callable.
handler();
Primitives such as string and number are fine; a homomorphic mapped type applied to a primitive leaves the primitive as it is. It works correctly on arrays too, turning string[] into readonly string[]. But functions break: the mapped type walks over the properties of a function type, doesn't carry the call signature, and what remains is an empty object that can't be called (TS2349). The fix is to separate functions with a conditional type:
type DeepReadonly<T> =
T extends (...args: any[]) => unknown ? T :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
interface Settings {
theme: { color: string; fonts: string[] };
onChange: (value: string) => void;
}
const settings: DeepReadonly<Settings> = {
theme: { color: "dark", fonts: ["Inter"] },
onChange: (value) => console.log(value),
};
// Error: Cannot assign to 'color' because it is a read-only property.
settings.theme.color = "light";
// Error: Property 'push' does not exist on type 'readonly string[]'.
settings.theme.fonts.push("Arial");
settings.onChange("ok"); // ok
There are three branches: if it's a function, leave it alone; if it's an object (arrays included), make every property readonly and repeat the same step one level down; if it's a primitive, don't touch it. Primitives are the natural stopping point; the recursion ends there.
DeepPartial
A deep version of Partial is often needed as well, for example to send only the changed part of a settings object. Here we handle arrays separately too, because "part of the fonts list" doesn't mean much; an array comes whole or not at all:
type DeepPartial<T> =
T extends (...args: any[]) => unknown ? T :
T extends readonly unknown[] ? T :
T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
T;
interface Settings {
theme: { color: string; fonts: string[] };
onChange: (value: string) => void;
}
const current: Settings = {
theme: { color: "dark", fonts: ["Inter"] },
onChange: (value) => console.log(value),
};
function applyPatch(patch: DeepPartial<Settings>): Settings {
return { ...current, ...patch, theme: { ...current.theme, ...patch.theme } };
}
const next = applyPatch({ theme: { color: "light" } });
console.log(next.theme.color, next.theme.fonts); // light [ 'Inter' ]
patch can now be as small as { theme: { color: "light" } }; we don't have to pass fonts or onChange. What if we hadn't written the array and function branches? A naive DeepPartial turns string[] into (string | undefined)[] and onChange into the empty object type {}; neither is what we want.
Loops at the Type Level: Recursive Conditional Types
In the examples so far, recursion followed a data structure. Since TypeScript 4.1, conditional types can call themselves directly in their branches, which means building loops at the type level. RouteParams from the previous post was an example. A simpler one is a type that repeats a string a given number of times:
type Repeat<S extends string, N extends number, Count extends unknown[] = []> =
Count["length"] extends N ? "" : `${S}${Repeat<S, N, [...Count, unknown]>}`;
type Three = Repeat<"ab", 3>; // "ababab"
There are no counters at the type level, so we use a tuple's length as one: at every step we add an element to Count, and we stop when the length equals N. The stopping condition, the base case, is a must for every recursive type.
Recursion Limits and Tail Recursion
To avoid infinite loops, the compiler limits nested expansions. Try it with Repeat and you can see the limit concretely:
type Repeat<S extends string, N extends number, Count extends unknown[] = []> =
Count["length"] extends N ? "" : `${S}${Repeat<S, N, [...Count, unknown]>}`;
type R47 = Repeat<"a", 47>; // fine
// Error: Type instantiation is excessively deep and possibly infinite.
type R48 = Repeat<"a", 48>;
In TypeScript 7.0.2 this type works with 47 repetitions and gives TS2589 at 48. The exact number varies with the shape of the type; what matters is that the limit is much closer than you'd think. The reason: the result of each step is used inside a template literal, so the compiler has to wait for the inner call's result, and the expansions pile up.
TypeScript 4.5 brought a fix for this: if a branch of a conditional type is directly another conditional type call (tail position), the compiler keeps the loop going without piling up intermediate expansions. To get there, we carry the result in an accumulator parameter:
type RepeatFast<S extends string, N extends number, Count extends unknown[] = [], Acc extends string = ""> =
Count["length"] extends N ? Acc : RepeatFast<S, N, [...Count, unknown], `${Acc}${S}`>;
type F999 = RepeatFast<"a", 999>; // fine
// Error: Type instantiation is excessively deep and possibly infinite.
type F1000 = RepeatFast<"a", 1000>;
A type doing the same job now works up to 999 repetitions and stops at 1000. The difference is a single thing: the self-call's result isn't placed inside another type; it is the branch itself. It's the same idea as carrying a value in an accumulator parameter in JavaScript and returning it at the end.
Common Pitfalls
The first is forgetting the base case, or writing one that can never be reached. In a call like Repeat<"a", -1>, the length will never be -1, so the recursion runs until the limit and you get TS2589 again. If the error message says "possibly infinite", look at the stopping condition first.
The second is forgetting that DeepReadonly treats every object the same. The mutating methods of classes like Map and Set are functions, and our type doesn't touch functions. So set can still be called on DeepReadonly<Map<string, number>>. Types like these need a branch of their own.
The third is performance. Deeply recursive types, especially combined with large unions, can noticeably slow down the editor and type checking. Being able to write something at the type level doesn't mean you should write everything there; weigh readability and checking time as well.
A Small Experiment
★☆☆ Predict whether it compiles: given type Nested = number | Nested[];, consider const a: Nested = [1, [2, [3, [4]]]]; and const b: Nested = [1, ["2"]];. Which one errors?
★★☆ Add a branch for Map to DeepReadonly: if T extends Map<infer K, infer V>, return ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>. Then watch the set call fail.
★★★ Write a tail-recursive Reverse<S> that reverses a string: Reverse<"abc"> should be "cba". Hint: the `${infer First}${infer Rest}` pattern and an Acc parameter.
One last question opens the door to the next post: can you pass a value of type DeepReadonly<Settings> where Settings is expected? Try it: the compiler objects for theme.fonts, because you can't assign a readonly array to a mutable one; the other direction is fine. Assignability has a direction. In the next post we'll discuss that direction systematically: covariance, contravariance, strictFunctionTypes and the in/out variance annotations.