
Recursive Type Guards in TypeScript with lazy
Define a guard once, let it refer to itself safely, and use it to validate tree-shaped values without turning your runtime checks into a schema framework.
The quick answer
Wrap the guard factory with lazy, then refer to the resulting guard for each recursive child.
import { arrayOf, isString, lazy, typedStruct } from 'is-kit';import type { Predicate } from 'is-kit';type Tree = {readonly value: string;readonly children: readonly Tree[];};const isTree: Predicate<Tree> = lazy(() =>typedStruct<Tree>()({value: isString,children: arrayOf(isTree),}),);isTree({value: 'root',children: [{ value: 'leaf', children: [] }],}); // true
isTree is still an ordinary reusable type guard. It works in branches, with safeParse, and anywhere a Predicate<Tree> is accepted.
Why a recursive guard needs lazy
A tree guard needs itself to check every child. Defining it eagerly reads the variable before that variable has been initialized.
const isTree = typedStruct<Tree>()({value: isString,children: arrayOf(isTree),// ^ Cannot access 'isTree' before initialization.});
lazy delays the factory until the first value is checked. By then, isTree has been assigned, so the child guard can safely refer to it.
The Predicate<Tree> annotation is also intentional.lazy delays runtime construction, but TypeScript still analyzes the initializer. Because isTree refers to itself, the annotation gives the compiler a type before it resolves that recursive reference. lazy<Tree>(...) alone does not break the inference cycle.
lazy delays guard construction. It does not make a recursive value safe by itself.Keep the existing type and the guard aligned
typedStruct<Tree>() makes the existing TypeScript type the source of truth for the object fields. Missing, extra, or incompatible guard fields become compile-time errors while the guard remains a plain runtime function.
Use arrayOf(isTree) for a homogeneous child list. Each child must satisfy the same guard, regardless of how deeply it is nested.
For more on keeping a hand-written guard aligned with an existing type, see the type-guard synchronization guide.
Validate recursive JSON at the boundary
JSON text is untrusted input. Use safeJsonParse to decode it to unknown, then apply the same recursive guard.
import { safeJsonParse } from 'is-kit';function readTree(input: string) {const result = safeJsonParse(input, isTree);if (!result.valid) {return undefined;}return result.value;}readTree('{"value":"root","children":[]}');// { readonly value: string; readonly children: readonly Tree[] } | undefined
Invalid JSON and an invalid tree both return { valid: false }. The helper does not coerce values, fill missing fields, or transform the tree.
Compose recursive unions when the node kinds differ
Recursive data does not have to be one uniform object shape. Combine leaf and branch guards with oneOf, then make only the branch variant refer to the lazy guard.
import {arrayOf,isString,lazy,oneOf,oneOfValues,typedStruct,} from 'is-kit';import type { Predicate } from 'is-kit';type File = {readonly kind: 'file';readonly name: string;};type Directory = {readonly kind: 'directory';readonly name: string;readonly children: readonly Node[];};type Node = File | Directory;const isNode: Predicate<Node> = lazy(() =>oneOf(typedStruct<File>()({kind: oneOfValues('file'),name: isString,}),typedStruct<Directory>()({kind: oneOfValues('directory'),name: isString,children: arrayOf(isNode),}),),);
Literal kind fields keep the two node variants explicit at runtime and give TypeScript a discriminated union after a successful check.
A tree is not a cyclic graph
Recursive JSON is a tree: JSON cannot represent an object pointing back to itself. In-memory JavaScript objects can contain cycles, and the same recursive guard will keep following them.
type MutableTree = {value: string;children: MutableTree[];};const node: MutableTree = { value: 'root', children: [] };node.children.push(node);isTree(node);// May recurse until the call stack is exhausted.
lazy caches the predicate created by its factory, not previously visited input objects. If cyclic graphs are part of the input contract, add explicit cycle handling outside the guard.
Use recursion only where it clarifies the contract
- Use
lazywhen a guard directly or indirectly refers to itself. - Use
typedStructwhen an existing object type should stay aligned with the recursive guard. - Use
safeJsonParseat JSON text boundaries. - Do not expect
lazyto detect cycles, coerce values, or produce path-rich validation errors.
See the lazy API reference for factory caching details and the parse API reference for the full safeJsonParse contract.