Advanced Type Manipulation: Recursive and Branded Types (Part 2)
Build recursive types for nested data like JSON, write DeepReadonly and DeepPartial utilities, and combine variadic tuples, key remapping, and branded types into robust domain-modeling tools.
Some data structures are naturally recursive — JSON values, trees, nested configuration objects — and TypeScript types can be recursive too, referring to themselves in their own definition. This lesson covers recursive types, the deep utility types they make possible, and rounds out the advanced type-manipulation toolkit with variadic tuples, key remapping, and branding applied to nested structures.
Recursive types and the JSON type
A recursive type is one whose definition refers to itself, directly or indirectly. TypeScript supports this for interfaces, type aliases involving object/array/tuple shapes, and conditional types, though a plain type alias that recurses through a union without ever 'landing' inside an object or array boundary can hit the compiler's recursion depth limits. The classic example is modeling arbitrary JSON data, which can nest arbitrarily deeply: a JSON value is a primitive, or an array of JSON values, or an object whose values are JSON values — a definition that necessarily refers to itself.
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; reads exactly like that description. Because Json[] and { [key: string]: Json } both refer back to Json, TypeScript can represent JSON values of any nesting depth with one finite type definition — the recursion in the type mirrors the recursion in the data itself.
Recursive types like this are how you get real type safety over genuinely tree-shaped data — configuration files, API responses with nested objects, AST-like structures — without flattening everything to any at some arbitrary depth.
DeepReadonly and DeepPartial
TypeScript's built-in Readonly<T> and Partial<T> only apply one level deep — Readonly<T> makes T's own top-level properties readonly, but a nested object property is still fully mutable through that top-level property. A DeepReadonly<T> needs to recurse: for each property, if its type is itself an object, recursively apply DeepReadonly to it before also marking it readonly at this level; otherwise leave primitive properties as-is (just marked readonly).
This is a mapped type combined with a conditional type, exactly like the previous lesson's techniques, except the conditional type's 'true' branch calls the utility type itself — the recursive step. DeepPartial<T> follows the identical shape, but makes every property (at every depth) optional instead of readonly, which is extremely useful for representing 'a partial update patch' against a deeply nested object without needing every nested field specified.
Recursive mapped/conditional type combinations like these are genuinely some of the most powerful and most misused tools in TypeScript — powerful because they can express real invariants over arbitrarily deep data, misused because it's easy to write one that's technically correct but so deeply nested the compiler's error messages (and your own ability to read them) become unreadable. Keep recursive utility types simple and well-named, and lean on intermediate type aliases.
Variadic tuples and key remapping, revisited at depth
Variadic tuple types let you write generic functions over tuples of arbitrary, but still precisely tracked, length — for example, a concat<T extends unknown[], U extends unknown[]>(a: T, b: U): [...T, ...U] whose return type is the exact concatenation of the two input tuples' element types, in order. This composes with recursion when you build utilities that need to process a tuple element-by-element at the type level, peeling off one element at a time with a conditional type (T extends [infer Head, ...infer Tail] ? ... : ...) and recursing on the remainder.
Key remapping (the as clause inside a mapped type, { [K in keyof T as NewKeyExpr]: ... }) combines with template literal types to generate whole families of derived property names from an existing type's keys — for instance, turning every property foo on T into both a getter method name getFoo and a setter method name setFoo in one mapped type, using Capitalize<string & K> inside a template literal to transform the casing of each key as it's remapped.
These techniques compound: a realistic advanced utility type might use key remapping with a template literal to generate new property names, a conditional type to decide each property's new value type, and recursion to handle nested objects — all three ideas from across this two-part module working together in a single utility.
Branded types in nested, recursive structures
Branding (from the previous module) composes with recursive and deep types too: a DeepReadonly<Config> where Config contains a branded UserId field keeps that field both deeply readonly and still incompatible with a plain string — the two techniques don't interfere with each other, because branding is just an intersection with a marker property, and deep-readonly transformation only touches mutability, not the underlying type identity.
This lets you build genuinely robust domain models: deeply immutable snapshots of application state where individual identifier and unit fields are still protected from being mixed up with structurally-identical but semantically different values elsewhere in the same tree.
Recursive DeepReadonly and the Json type
The array branch is checked before the general object branch, since arrays are objects too but need readonly Array<...> treatment rather than a mapped-type-over-keys treatment. The final : T branch is the recursion's base case — primitives (string, number, boolean, null, etc.) are returned unchanged, which is what stops the recursion from continuing forever. Json recurses through both Json[] and { [key: string]: Json }, matching how real JSON data nests arrays and objects inside each other to arbitrary depth.
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };Code Examples
Both the top-level theme and the nested limits.maxUsers are protected from mutation, because DeepReadonly recursed into the limits object and applied the same readonly treatment there too — something the built-in one-level-deep Readonly<T> cannot do.
Common mistake: Using the built-in Readonly<Settings> instead would only protect theme and limits as a whole reference; s.limits.maxUsers = 100 would still compile, because Readonly doesn't look inside nested object properties.
Every value nested inside payload, no matter how deeply, is checked against the same Json definition — objects contain Json values, arrays contain Json values, and the recursion bottoms out at primitives, so a single type definition covers arbitrarily deep, arbitrarily shaped JSON data.
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Settings {
theme: string;
limits: {
maxUsers: number;
maxProjects: number;
};
}
type FrozenSettings = DeepReadonly<Settings>;
function apply(s: FrozenSettings) {
s.theme = "dark"; // Error: read-only property
s.limits.maxUsers = 100; // Error: read-only property
}type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
const payload: Json = {
name: "Ada",
active: true,
tags: ["engineer", "pioneer"],
meta: {
createdAt: "2026-01-01",
nested: { deeper: [1, 2, { evenDeeper: true }] }
}
};Recursive type unwinding
Each recursive step handles one level of nesting, delegating deeper levels to another application of the same type — recursion stops at primitives.
DeepReadonly<Settings>
theme: string -> readonly (base case, primitive)
limits: { ... } -> object, recurse:
DeepReadonly<{ maxUsers; maxProjects }>
maxUsers: number -> readonly (base case)
maxProjects: number -> readonly (base case)Important Rules
A recursive type must have a base case (usually primitives passed through unchanged) or it will never resolve — mirror the structure of ordinary recursive functions, which also need a stopping condition.
Check more specific structural cases (like arrays) before more general ones (like 'any object') in a recursive conditional type, since arrays are technically objects too and need different handling.
Keep recursive utility types named and composed from smaller pieces rather than writing one giant nested expression — error messages against deeply recursive inline types are extremely hard to read.
Branding, deep-readonly/deep-partial transformation, key remapping, and recursion are independent techniques that compose freely — you can combine any subset of them in one utility type.
Where recursive types are essential
State management libraries that expose immutable snapshots (so a reducer or component can never accidentally mutate shared state), JSON schema validators, and configuration systems that load deeply nested settings files all rely on recursive types like Json and DeepReadonly to give real, checked guarantees over data whose shape isn't known to be flat in advance.
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] }; // only one level deep, despite the name type DeepReadonly<T> = T extends (infer U)[] ? readonly DeepReadonly<U>[]: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }: T;
Without recursing into each property's own type when that property is itself an object, the 'deep' in the name is misleading — nested objects remain fully mutable through the outer readonly reference.
[multiple-choice] Why does a recursive type like Json need a base case such as string | number | boolean | null? A. It doesn't — recursive types work without one B. Without a non-recursive branch, the type definition would never bottom out at an actual concrete type C. It's required by TypeScript syntax rules, not by the logic of the type D. Base cases are only needed for recursive functions, not types
B. Without a non-recursive branch, the type definition would never bottom out at an actual concrete type — Just like a recursive function needs a stopping condition, a recursive type needs at least one branch that doesn't refer back to itself, or the recursion has nothing to terminate on.
[multiple-choice] What's the key difference between the built-in Readonly<T> and a hand-written DeepReadonly<T>? A. There is no difference B. Readonly<T> only protects top-level properties; DeepReadonly<T> recursively protects nested object properties too C. DeepReadonly<T> is slower at runtime D. Readonly<T> works on arrays, DeepReadonly<T> does not
B. Readonly<T> only protects top-level properties; DeepReadonly<T> recursively protects nested object properties too — Readonly<T> applies only one level deep, so nested objects remain mutable through their reference; DeepReadonly<T> recurses into every nested object to make the whole structure immutable.
[true-false] Branded types stop working correctly once they're nested inside a recursive or deeply-transformed type like DeepReadonly. A. True B. False
B. False — Branding and deep transformations are independent, composable techniques — a branded field nested inside a DeepReadonly structure stays both deeply immutable and structurally distinct from other same-underlying-type brands.
Write a recursive DeepPartial<T> type that makes every property optional at every level of nesting (mirroring DeepReadonly but with ? instead of readonly). Apply it to a Settings interface with a nested limits object and confirm that both theme and limits.maxUsers can be omitted.
type DeepPartial<T> = T extends (infer U)[] ? DeepPartial<U>[] : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; interface Settings { theme: string; limits: { maxUsers: number; maxProjects: number }; } const patch: DeepPartial<Settings> = { limits: { maxUsers: 50 } // maxProjects and theme both omittable };
Follow the same array-then-object-then-primitive branching as DeepReadonly, but use [K in keyof T]?: instead of readonly [K in keyof T]:.