You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
1 | 2 | 3 can be assigned to 1 | 2 when using a recursive type #63454
This is the behavior in every version I tried, and I reviewed the FAQ for entries about generics, in particular Why is A<string> assignable to A<number> for interface A<T> { }?, which does not apply to this issue.
🤖 Thank you for your issue! I've done some analysis to help get you started. This response is automatically generated; feel free to 👍 or 👎 this comment according to its usefulness.
(67%) microsoft/typescript#51318: Nested generic object of unions is not assignable to equivalent union of nested generic objects
(67%) microsoft/typescript#61100: Instances of generic interfaces are incompatible because their generic arguments are incompatible; even when two instances in question are structurally identical
(67%) microsoft/typescript#39169: Type '{ foo: T1 | T2}' is not assignable to type '{ foo: T1; } | { foo: T2; }'.
(66%) microsoft/typescript#45261: Structurally assignable generic types with an object contraint are not assignable, when their properties are a type expression of the constraint
If your issue is a duplicate of one of these, feel free to close this issue. Otherwise, no action is needed.
Possibly Working as Intended
It looks like this behavior might be intentional. Here's what a computer had to say about it.
BuildType produces a self-similar (potentially infinite) structure: every time the checker tries to compare two such values it has to descend into another layer of { x: … }. To avoid non-terminating or exponential comparisons, the checker gives up after a fixed recursion/instantiation depth (currently 50) and treats the remaining part of the type as any. Once that fallback to any happens the two sides trivially become mutually assignable, so both assignments compile.
This is a deliberate safety/ performance trade-off in the implementation of TypeScript’s structural type system rather than a genuine soundness guarantee. If you need the assignment to fail you have to keep the recursive structure shallow enough (or introduce nominal branding) so that the comparison finishes before the depth limit is reached.
We can't seem to know ahead of time that BuildType is going to terminate (and indeed many instantiations of BuildType do not terminate). If you can come up with a heuristic that reliably detects this but doesn't falsely allow infinite recursion in other places, we can evaluate that, but we've looked at this a bunch of times and come up short.
Reacted by carljohnsonnewhampshire-hue and Tim Oelkers
carljohnsonnewhampshire-hue commented on Apr 30, 2026
Hey, thanks for answering. Can you please give me some suggestions on how I can intentionally circumvent typescript's heuristics in that regard? I know that my recursive type terminates, I don't want typescript to just throw hands in the air when I see the type figured out correctly right there in LSP tooltip in VSCode, so the type is known, it is correct, but typescript just doesn't check it -- can I intentionally just circumvent this in any way? I don't want typescript to stop checking my code just because it thinks that it never terminates. Can't I just let it do the thing? I personally wouldn't care if I got "instantiation too deep" sort of errors -- that's a bug in the code, not typescript's problem. I understand typescript design choices here, however, right now I can't seem to write any recursive types beyond the most simplest and most primitive ones, like I can't do any advanced transformations on nested record types because typescript just does not check them. I love typescript and it is a very powerful language, and I respect yours and others work very much, I'm really grateful and understand that there's a lot of consideration going into every decision and a lot of edge cases, but can you please give me some advice on how I can workaround typescript's heuristics for recursive mapped types so that it doesn't think that it's pointless to check my code?
carljohnsonnewhampshire-hue commented on May 1, 2026
Like, that's the simplest recursive type ever, there has to be some way to write the type definition differently or something to let typescript actually check assignments involving this computed type. Is there some "tail-recursive" kind of way to write recursive record mapped types or anything like that? Please, anything
I'm taking "please, anything" seriously. You can unroll the recursion to a depth at which you'd expect to see an actual structural difference between the types:
Hey, thanks so much! I've seen you on stackoverflow, you're a legend. Yeah I figured out exactly this way in the meantime, it's a shame (not really) that nobody uses C pre-processor with typescript, it'd be a breeze to do that with it, unfortunately my actual type is quite a bit more complicated so I have to literally copy paste around 15 lines of code multiple times, which is obviously not cool, but, well, at least it works. I'll stick to this approach for now, thanks for answering.
If you don't mind explaining at least a bit, how to push a new generic type instantiation on top of this generic type instantiation stack so that typescript doesn't think it's the same type three or more times in a row? This manual recursion unrolling works, but the moment I try to move some of the repeated boilerplate that also uses T like & Boilerplate_<T> instead of & { x: 1 } it's back to the same issue where typescript stops checking the code, so I have to copy paste literally dozen of lines of code multiple times just to insert some metadata into each nested object, recursively, you know what I mean? Like, this Boilerplate_ type doesn't even do recursion, yet when it's present in multiple of those BuildType definitions it triggers that checker heuristic I'm trying to avoid, I don't get it, why? As I've said Boilerplate_ is a helper type that's not recursive.
I'm wondering why is recursive mapped type over an infinite circular type is perfectly fine and works as expected but only if nothing is done at all on the values retrieved by the keys?
So, this is perfectly fine, allowed, and works as expected:
typeBuildType<T>={[KinkeyofT]: BuildType<T[K]>;}extends infer O ? O : Neverdeclareleta: BuildType<{a:{b:{c:{d:{e:{f:{g:{h:{i:{j:{k:{l:1|2|3}}}}}}}}}}}}>;declareletb: BuildType<{a:{b:{c:{d:{e:{f:{g:{h:{i:{j:{k:{l:1|2}}}}}}}}}}}}>;a=b;b=a;// Fails, as expected
But any tiniest change to BuildType makes the checker stop doing its job. Why? Like, even without intersection types, any tiniest change breaks the checker:
Is there any reason for that? I just don't see why one use case is perfectly allowed and functional, and essential for types like RequiredDeep and so on, but the second one is totally broken? Typescript works fine with recursive, circular, and infinite types, what's the reason for this limitation for the second case? And for tuple types none of the manual unrolling hacks are needed, mapped types over tuples can be any depth and use intersections and for them it works just fine.
I don't immediately know why this happens when you refactor to Boilerplate<T>. I'd guess it's TS taking some inaccurate shortcut with variance markers, but I'm not sure why or where. Similar with changing your mapped types from homomorphic to nonhomomomomomomomoCRASH. Someone needs to know more about the internals here to say why this happens. As for working around it, at some point I'd give up and say "I intend BuildType<T> to be invariant in T" and write
Or, maybe covariant or contravariant, but the idea is to force TS to care about variance right at the top. TS's type system isn't sound or complete so sometimes you have to just choose among different options for false positives or false negatives. Maybe someone else has advice here?
carljohnsonnewhampshire-hue commented on May 3, 2026
Joe Calzaretta (@jcalz) thanks so much for taking the time for answering me, your suggestion helped tremendously, the following is a better workaround than copy-pasting the code multiple times:
Activity
carljohnsonnewhampshire-hue commentedon Apr 30, 2026
Moreover, the following also passes without any issue:
Even though when I hover over
numandstrin an IDE I see their types are built correctly as expected:carljohnsonnewhampshire-hue commentedon Apr 30, 2026
Additionally, if the nesting within object types is at least one level shallower, then both assignments fail as expected:
RyanCavanaugh commentedon Apr 30, 2026
🤖 Thank you for your issue! I've done some analysis to help get you started. This response is automatically generated; feel free to 👍 or 👎 this comment according to its usefulness.
Similar Issues
Here are the most similar issues I found
If your issue is a duplicate of one of these, feel free to close this issue. Otherwise, no action is needed.
Possibly Working as Intended
It looks like this behavior might be intentional. Here's what a computer had to say about it.
BuildType produces a self-similar (potentially infinite) structure: every time the checker tries to compare two such values it has to descend into another layer of
{ x: … }. To avoid non-terminating or exponential comparisons, the checker gives up after a fixed recursion/instantiation depth (currently 50) and treats the remaining part of the type asany. Once that fallback toanyhappens the two sides trivially become mutually assignable, so both assignments compile.This is a deliberate safety/ performance trade-off in the implementation of TypeScript’s structural type system rather than a genuine soundness guarantee. If you need the assignment to fail you have to keep the recursive structure shallow enough (or introduce nominal branding) so that the comparison finishes before the depth limit is reached.
RyanCavanaugh commentedon Apr 30, 2026
We can't seem to know ahead of time that
BuildTypeis going to terminate (and indeed many instantiations ofBuildTypedo not terminate). If you can come up with a heuristic that reliably detects this but doesn't falsely allow infinite recursion in other places, we can evaluate that, but we've looked at this a bunch of times and come up short.carljohnsonnewhampshire-hue commentedon Apr 30, 2026
Hey, thanks for answering. Can you please give me some suggestions on how I can intentionally circumvent typescript's heuristics in that regard? I know that my recursive type terminates, I don't want typescript to just throw hands in the air when I see the type figured out correctly right there in LSP tooltip in VSCode, so the type is known, it is correct, but typescript just doesn't check it -- can I intentionally just circumvent this in any way? I don't want typescript to stop checking my code just because it thinks that it never terminates. Can't I just let it do the thing? I personally wouldn't care if I got "instantiation too deep" sort of errors -- that's a bug in the code, not typescript's problem. I understand typescript design choices here, however, right now I can't seem to write any recursive types beyond the most simplest and most primitive ones, like I can't do any advanced transformations on nested record types because typescript just does not check them. I love typescript and it is a very powerful language, and I respect yours and others work very much, I'm really grateful and understand that there's a lot of consideration going into every decision and a lot of edge cases, but can you please give me some advice on how I can workaround typescript's heuristics for recursive mapped types so that it doesn't think that it's pointless to check my code?
carljohnsonnewhampshire-hue commentedon May 1, 2026
Even the following simplest recursive type fails:
Like, that's the simplest recursive type ever, there has to be some way to write the type definition differently or something to let typescript actually check assignments involving this computed type. Is there some "tail-recursive" kind of way to write recursive record mapped types or anything like that? Please, anything
jcalz commentedon May 3, 2026
I'm taking "please, anything" seriously. You can unroll the recursion to a depth at which you'd expect to see an actual structural difference between the types:
Playground link to code
carljohnsonnewhampshire-hue commentedon May 3, 2026
Hey, thanks so much! I've seen you on stackoverflow, you're a legend. Yeah I figured out exactly this way in the meantime, it's a shame (not really) that nobody uses C pre-processor with typescript, it'd be a breeze to do that with it, unfortunately my actual type is quite a bit more complicated so I have to literally copy paste around 15 lines of code multiple times, which is obviously not cool, but, well, at least it works. I'll stick to this approach for now, thanks for answering.
If you don't mind explaining at least a bit, how to push a new generic type instantiation on top of this generic type instantiation stack so that typescript doesn't think it's the same type three or more times in a row? This manual recursion unrolling works, but the moment I try to move some of the repeated boilerplate that also uses T like
& Boilerplate_<T>instead of& { x: 1 }it's back to the same issue where typescript stops checking the code, so I have to copy paste literally dozen of lines of code multiple times just to insert some metadata into each nested object, recursively, you know what I mean? Like, thisBoilerplate_type doesn't even do recursion, yet when it's present in multiple of thoseBuildTypedefinitions it triggers that checker heuristic I'm trying to avoid, I don't get it, why? As I've saidBoilerplate_is a helper type that's not recursive.This is what I mean:
You know? Why does using
Boilerplate<T>trigger this heuristic if it's not itself even recursive?carljohnsonnewhampshire-hue commentedon May 3, 2026
I'm wondering why is recursive mapped type over an infinite circular type is perfectly fine and works as expected but only if nothing is done at all on the values retrieved by the keys?
So, this is perfectly fine, allowed, and works as expected:
But any tiniest change to
BuildTypemakes the checker stop doing its job. Why? Like, even without intersection types, any tiniest change breaks the checker:Is there any reason for that? I just don't see why one use case is perfectly allowed and functional, and essential for types like RequiredDeep and so on, but the second one is totally broken? Typescript works fine with recursive, circular, and infinite types, what's the reason for this limitation for the second case? And for tuple types none of the manual unrolling hacks are needed, mapped types over tuples can be any depth and use intersections and for them it works just fine.
jcalz commentedon May 3, 2026
I don't immediately know why this happens when you refactor to
Boilerplate<T>. I'd guess it's TS taking some inaccurate shortcut with variance markers, but I'm not sure why or where. Similar with changing your mapped types from homomorphic to nonhomomomomomomomoCRASH. Someone needs to know more about the internals here to say why this happens. As for working around it, at some point I'd give up and say "I intendBuildType<T>to be invariant inT" and writeOr, maybe covariant or contravariant, but the idea is to force TS to care about variance right at the top. TS's type system isn't sound or complete so sometimes you have to just choose among different options for false positives or false negatives. Maybe someone else has advice here?
carljohnsonnewhampshire-hue commentedon May 3, 2026
Joe Calzaretta (@jcalz) thanks so much for taking the time for answering me, your suggestion helped tremendously, the following is a better workaround than copy-pasting the code multiple times:
So you were right, making TS care about variance works around this issue!
carljohnsonnewhampshire-hue commentedon May 3, 2026
The following is even better in that it doesn't create an extra property in nested object types:
typescript-bot commentedon May 6, 2026
This issue has been marked as "Design Limitation" and has seen no recent activity. It has been automatically closed for house-keeping purposes.