TypeScript error TS2589 happens when the compiler stops evaluating a type because the type instantiation has become too deep. The type may be infinite, but it may also be finite and simply too expensive for TypeScript to evaluate.
The most common fixes are to reduce recursive type work, add a depth limit, avoid generating huge unions, or change the API so TypeScript validates one value instead of enumerating every possible value.
Examples in this article were tested with TypeScript 5.5.4.
What is TS2589 in TypeScript?
TS2589 is the TypeScript compiler error:
Type instantiation is excessively deep and possibly infinite.
It means TypeScript was evaluating a generic type and hit an internal safety limit. This protects the compiler from spending too much time or memory expanding recursive or highly nested types.
This can happen even when the type is not logically infinite. A type can have a valid stopping condition and still require too many expansion steps for the compiler to complete.
What causes TS2589?
TS2589 is usually caused by repeated type expansion. Common triggers include:
- recursive conditional types
- recursive object path builders
- recursive tuple builders
- template literal types that parse strings
- mapped types over deeply nested objects
- conditional types that distribute over large unions
- generic library types that compose several advanced type operations
The underlying issue is compiler cost. TypeScript is being asked to prove or construct a type through many nested instantiations.
A real-world example: type-safe form field paths
Many form libraries, validation helpers, and query builders use string paths to refer to nested values:
"customer"
"customer.billingAddress"
"customer.billingAddress.street"
It is natural to want TypeScript to validate those strings. Given a form model like this:
type CheckoutForm = {
customer: {
billingAddress: {
street: string;
postalCode: string;
};
};
payment: {
cardNumber: string;
expirationDate: string;
};
};
you might want a type that produces a union like this:
type CheckoutField =
| "customer"
| "customer.billingAddress"
| "customer.billingAddress.street"
| "customer.billingAddress.postalCode"
| "payment"
| "payment.cardNumber"
| "payment.expirationDate";
That union can power autocomplete and prevent typos:
function registerField(name: CheckoutField) {
// register a field with a form library
}
registerField("customer.billingAddress.street");
registerField("payment.cardNumber");
So far, this is a useful and reasonable type-level feature.
Where the useful type becomes expensive
The field path idea is useful because it catches invalid strings at compile time. The cost is that TypeScript has to recursively walk the object type and build a string literal union.
A generic implementation might look like this:
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
type FieldPath<T> = T extends Primitive
? never
: {
[Key in keyof T & string]: T[Key] extends Primitive
? Key
: Key | `${Key}.${FieldPath<T[Key]>}`;
}[keyof T & string];
This type walks through an object and builds a union of valid dot-separated paths. It is the bridge between the practical API and the TS2589 error: the same recursion that gives helpful autocomplete can become too expensive on a large enough input.
For each key:
- if the value is primitive, the path is just the key
- if the value is another object, the path can be the key or the key followed by a nested path
For the CheckoutForm type, FieldPath<CheckoutForm> is useful:
type CheckoutField = FieldPath<CheckoutForm>;
const field: CheckoutField = "customer.billingAddress.street";
The problem appears when this helper receives a very deep object type. That can happen with generated API types, CMS schemas, configuration objects, database query result types, or heavily nested validation schemas.
Here is a simplified generated schema type:
type GeneratedCheckoutSchema<
Depth extends number,
Steps extends unknown[] = []
> = Steps["length"] extends Depth
? {
cardNumber: string;
expirationDate: string;
securityCode: string;
}
: {
checkout: GeneratedCheckoutSchema<Depth, [unknown, ...Steps]>;
};
This represents a deeply nested object. At the leaf, it has fields like cardNumber, expirationDate, and securityCode. Until it reaches that leaf, it keeps nesting under checkout.
Now apply the field path type:
type CheckoutField = FieldPath<GeneratedCheckoutSchema<1000>>;
At this point, TypeScript reports:
Type instantiation is excessively deep and possibly infinite.
The field path helper is not obviously wrong. The generated schema is not necessarily impossible to describe. The problem is the amount of type-level work.
To build the full path union, TypeScript has to keep expanding:
"checkout"
"checkout.checkout"
"checkout.checkout.checkout"
"checkout.checkout.checkout.checkout"
and so on, until it reaches the leaf fields. With a depth of 1,000, that is too much recursive type instantiation.
How do you fix TS2589?
To fix TS2589, reduce the amount of type-level work TypeScript has to perform. In practice, that usually means one of these approaches:
- Add a depth limit to recursive utility types.
- Return a fallback type after a safe recursion limit.
- Avoid generating huge unions unless the union is truly needed.
- Validate one provided value instead of enumerating every valid value.
- Prevent unnecessary distribution over large unions.
- Simplify public types when perfect precision is too expensive.
The right fix depends on what the type is trying to provide: exactness, autocomplete, validation, or a convenient public API.
Fix 1: Add a depth limit
One reliable fix is to add an explicit depth limit. Instead of asking TypeScript to compute paths forever, decide how deep the type should be precise.
Here is a capped version of the field path helper:
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
type FieldPathWithDepth<
T,
MaxDepth extends number,
Depth extends unknown[] = []
> = T extends Primitive
? never
: Depth["length"] extends MaxDepth
? string
: {
[Key in keyof T & string]: T[Key] extends Primitive
? Key
: Key | `${Key}.${FieldPathWithDepth<
T[Key],
MaxDepth,
[unknown, ...Depth]
>}`;
}[keyof T & string];
This version adds a Depth parameter. Each recursive step pushes one item into the Depth tuple:
[unknown, ...Depth]
Then the type checks whether the maximum depth has been reached:
Depth["length"] extends MaxDepth
If it has, the type returns string:
? string
That fallback is a design choice. It means "TypeScript will validate paths up to this depth, but beyond that, accept a string instead of continuing to recurse."
You could choose a different fallback depending on your API:
-
stringif deeper paths should still be allowed -
neverif deeper paths should be rejected - a branded fallback type if you want to make the limit visible
The capped helper can now be used like this:
type CheckoutField = FieldPathWithDepth<GeneratedCheckoutSchema<1000>, 6>;
This gives useful type safety for the first six levels and avoids asking TypeScript to enumerate all 1,000 levels.
This approach is often the best fix for public utility types. If a type can receive arbitrarily deep user input, it should usually have a depth limit.
Use this approach when:
- exactness past a certain depth is not important
- the type is part of a public API
- the input can be deeply nested
- predictable compile performance matters
- a fallback type is better than a compiler failure
For form field paths, this tradeoff is usually reasonable. Most real forms are not designed around hundreds of meaningful nested levels. If a generated type happens to be very deep, preserving perfect autocomplete at every level may not be worth breaking the compiler.
Fix 2: Avoid generating every possible path
Sometimes the best fix is to change the API shape.
The original FieldPath<T> approach eagerly generates a union of every valid path in the object. That can be expensive:
type CheckoutField = FieldPath<CheckoutForm>;
This is useful for autocomplete, but it is not always necessary. If callers usually provide one path at a time, you can validate the specific path they provided instead of generating the entire union up front.
Here is a type that resolves the value at a single path:
type PathValue<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? PathValue<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;
This type reads a path string from left to right.
Given:
type Street = PathValue<CheckoutForm, "customer.billingAddress.street">;
the result is:
type Street = string;
You can use that in an API:
declare function getFieldValue<T, Path extends string>(
form: T,
path: Path & (PathValue<T, Path> extends never ? never : Path)
): PathValue<T, Path>;
Then:
declare const checkoutForm: CheckoutForm;
const street = getFieldValue(
checkoutForm,
"customer.billingAddress.street"
);
This approach does not need to generate the full union of every possible field path. It only evaluates the path the caller actually passed.
That is a different tradeoff. You may get less complete autocomplete than a full path union, depending on how the API is used, but the compiler does much less work. For large schemas, that can be the difference between a usable type and TS2589.
Use this approach when:
- callers usually provide one path at a time
- generating every possible path is too expensive
- exact validation of a provided path is enough
- the full path union is mostly used for convenience
This is often a better design for very large generated models.
Fix 3: Avoid unnecessary distributive conditional types
Recursive object paths are not the only source of TS2589. Large unions can also trigger excessive type work, especially when combined with conditional types.
In TypeScript, a conditional type distributes over a union when the checked type is a naked type parameter:
type Distributive<T> = T extends string ? T[] : never;
Given a union, TypeScript applies the conditional type to each member:
type Result = Distributive<"a" | "b" | "c">;
// "a"[] | "b"[] | "c"[]
Distribution is often useful, but it can become expensive when the union is large or when the distributed branch performs more recursive work.
If distribution is not needed, wrap the type parameter in a tuple:
type NonDistributive<T> = [T] extends [string] ? T[] : never;
This makes TypeScript evaluate the union as a whole rather than distributing across every member.
The principle is the same as the field path example: reduce the amount of type-level work TypeScript has to perform.
How to diagnose TS2589 in real code
When TS2589 appears in a real project, start with the error location. TypeScript will usually point at the type alias, generic helper, or inferred expression that triggered the expansion.
From there, look for patterns that commonly cause deep instantiation:
- recursive conditional types
- recursive object path builders
- recursive tuple builders
- template literal types that parse strings recursively
- mapped types over nested objects
- conditional types that distribute over large unions
- generic types that call other generic types repeatedly
Then reduce the input size and see whether the error disappears.
For example, if this fails:
type CheckoutField = FieldPath<GeneratedCheckoutSchema<1000>>;
try a smaller version:
type CheckoutField = FieldPath<GeneratedCheckoutSchema<10>>;
If the smaller input compiles, the type is probably not inherently broken. It is failing because the compiler has to expand it too deeply.
This same technique applies to application code. Try a smaller object, smaller tuple, shorter string, or smaller union. If the error disappears, the issue is likely type expansion depth.
Choosing the right TS2589 fix
There is no single correct fix for every TS2589 error. The right solution depends on what the type is trying to accomplish.
If the type is a convenience utility, add a depth limit. This is usually the most practical fix. A type that works up to a reasonable depth is better than a type that fails on large inputs.
If the type must remain exact, try to reduce the amount of recursive work. Batch operations where possible. Avoid processing only one key, one tuple item, or one string segment per recursive step if the type can safely process more.
If the type is part of a public API, consider simplifying the exposed type. Library users usually care more about stable, understandable types than maximum theoretical precision.
If the type generates a large union for autocomplete, consider whether you can validate a specific user-provided value instead. Generating every possible value is often more expensive than checking one value.
If the type distributes over a large union, avoid unnecessary distribution. Preventing distribution can significantly reduce compiler work.
TS2589 checklist
When you encounter TS2589, work through this checklist:
- Find the type named in the error span.
- Look for recursion, distribution, template literal parsing, or nested mapped types.
- Try a smaller input and see whether the error disappears.
- If smaller inputs compile, treat the issue as type expansion depth.
- Add a recursion budget if exactness has a reasonable limit.
- Restructure the type or API if exactness is required.
- Avoid generating large unions unless the union is truly needed.
- Reduce distribution over large unions where possible.
- Prefer a simpler public type when the precise type is too expensive.
The goal is not to make TypeScript accept any possible type-level program. The goal is to design types that are useful, maintainable, and affordable for the compiler to evaluate.
FAQ
Does TS2589 mean my type is infinite?
Not always. TS2589 means TypeScript stopped evaluating a type because the instantiation became too deep. The type may be infinite, but it may also be finite and too expensive to evaluate.
What is the fastest way to fix TS2589?
The fastest fix is usually to add a depth limit to the recursive type or replace the precise type with a simpler fallback after a safe depth.
Why do field path types cause TS2589?
Field path types often generate a union of every valid nested string path in an object. On deeply nested or generated types, that recursive union can require too many type instantiations.
Should I use string as a fallback?
Use string when deeper values should still be accepted but no longer need precise autocomplete. Use never when deeper values should be rejected.
Can large unions cause TS2589?
Yes. Large unions can cause TS2589, especially when conditional types distribute over each union member and each branch performs additional recursive work.
Summary
-
TS2589is usually a compiler depth limit, not proof that your type is truly infinite. - Recursive conditional types, template literal types, mapped types, and large unions are common causes.
- A type-safe field path helper is a realistic example of a useful type that can become too expensive.
- The safest fix is often to add a depth limit.
- Another strong fix is to avoid generating every possible string path and validate one provided path instead.
Conclusion
TS2589 means TypeScript stopped evaluating a type because the instantiation became too deep. The type may be infinite, but it may also be finite and simply too expensive.
The field path example demonstrates a common real-world cause. A type that generates every valid nested path can be useful for forms and schemas, but it can also require a lot of recursive type work.
There are two practical ways to respond:
- add a depth limit so the type stays predictable
- change the API so TypeScript validates one provided path instead of generating every possible path
The main lesson is that advanced TypeScript types should be designed with compiler cost in mind. A good type is not only correct. It also needs to compile reliably.
Top comments (1)
I've encountered TS2589 when using recursive type definitions, do you think there's a way to increase the recursion limit or is refactoring the only solution? I'd love to swap ideas on this.