Skip to content

1 | 2 | 3 can be assigned to 1 | 2 when using a recursive type #63454

Closed as not planned

Description

🔎 Search Terms

"mutual assignability", "unsound union assignment", "recursive generics", "assignability", "weak assignability", "structural assignability", "deep recursive mapped type", "recursive conditional type assignability", "recursive conditional type", "union assignment", "conditional type distribution", "recursive type", "invariance", "covariance", "contravariant", "structural checking", "depth limit heuristics"

🕗 Version & Regression Information

  • 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.

⏯ Playground Link

https://www.typescriptlang.org/play/?#code/C4TwDgpgBA6gTgQzJOAeAKgPigXigbygA8AuKdKAXwG4BYAKAdEigCEBXASwBsATdcBAzY8AbXQBdKBCLAIAO14BnKKID2AIwBWEAMbAJDKFAD8BI8cuiA0lE7yoAawgg1AM3ISy8JClQcefkEMGwlMTAtjSgsydDpGel49bgQ4aG4IYCgAfQBGbLU4bIAmAqKAZjIAvgFIVEIEMkINJqhdMlyoAB8oYu6ocqohykx4pN0UtKgMrLyykqquGuCG1paCNo7+vsph0YYGOcKS+cG8I6Li6igAehuoBG5uNQB3CF4AGgeVGUh9d8O+WOfXOQMup2udwe3CUamhzzevAAhAd6FCYJxgAALNTsLLY6BKCBgVIIOSwRDICBwKAAcwU1M4uiYgjYSyCkGKwlwqkk0lkCmUqk0On0hnoxjM+EiVls9icLncniaMssxEWgVqEC56FCEQlaqoMuiBti8QY40m6UyUDkSmApWyFxOx0qbM1gi5qw260I7SgnR6fR6g12Yf2iWSqWt+Ig9sdzuKGuWnPqDzWrX9gd6wyoEaYcYdTsTpx5dqLTrBJUh90eCPeXwQPyIfzkvAL8eLVZBtsLCe7ENutZhcLrr3eKPoQA

💻 Code

type Wrapper<T> = { x: T };

type BuildType<T> = [T] extends [object]
  ? {
      [K in keyof T]: Wrapper<BuildType<T[K]>>
    }
  : T;

declare let _1_or_2_or_3: BuildType<{ a: { b: { c: 1 | 2 | 3 } } }>;
declare let _1_or_2: BuildType<{ a: { b: { c: 1 | 2 } } }>;

_1_or_2_or_3 = _1_or_2; // allowed, as expected
_1_or_2 = _1_or_2_or_3; // also allowed!

// Without the separate Wrapper generic
type BuildType2<T> = [T] extends [object]
  ? {
      [K in keyof T]: {
        x: BuildType2<T[K]>
      }
    }
  : T;

declare let test2___1_or_2_or_3: BuildType2<{ a: { b: { c: 1 | 2 | 3 } } }>;
declare let test2___1_or_2: BuildType2<{ a: { b: { c: 1 | 2 } } }>;

test2___1_or_2_or_3 = test2___1_or_2; // allowed, as expected
test2___1_or_2 = test2___1_or_2_or_3; // also allowed!

🙁 Actual behavior

1 | 2 | 3 is assignable to 1 | 2

🙂 Expected behavior

1 | 2 | 3 should not be assignable to 1 | 2

Additional information about the issue

No response

Activity

  1. carljohnsonnewhampshire-hue commented on Apr 30, 2026

    @carljohnsonnewhampshire-hue
    Author

    Moreover, the following also passes without any issue:

    type BuildType<T> = [T] extends [object]
    	? {
    			[K in keyof T]: { x: BuildType<T[K]> };
    		}
    	: T;
    
    declare let num: BuildType<{ a: { b: { c: string } } }>;
    declare let str: BuildType<{ a: { b: { c: number } } }>;
    
    // Both allowed!
    num = str;
    str = num;

    Even though when I hover over num and str in an IDE I see their types are built correctly as expected:

    let num: {
        a: {
            x: {
                b: {
                    x: {
                        c: {
                            x: string;
                        };
                    };
                };
            };
        };
    }
    let str: {
        a: {
            x: {
                b: {
                    x: {
                        c: {
                            x: number;
                        };
                    };
                };
            };
        };
    }
  2. carljohnsonnewhampshire-hue commented on Apr 30, 2026

    @carljohnsonnewhampshire-hue
    Author

    Additionally, if the nesting within object types is at least one level shallower, then both assignments fail as expected:

    type BuildType<T> = [T] extends [object]
    	? {
    			[K in keyof T]: { x: BuildType<T[K]> };
    		}
    	: T;
    
    declare let num: BuildType<{ a: { b: string } }>;
    declare let str: BuildType<{ a: { b: number } }>;
    
    // Both fail, as expected
    num = str;
    str = num;
  3. RyanCavanaugh commented on Apr 30, 2026

    @RyanCavanaugh
    Member

    🤖 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 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.

  4. RyanCavanaugh commented on Apr 30, 2026

    @RyanCavanaugh
    Member

    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.

  5. carljohnsonnewhampshire-hue commented on Apr 30, 2026

    @carljohnsonnewhampshire-hue
    Author

    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?

  6. carljohnsonnewhampshire-hue commented on May 1, 2026

    @carljohnsonnewhampshire-hue
    Author

    Even the following simplest recursive type fails:

    type BuildType<T> = [T] extends [object]
    	? {
    			[K in keyof T]: BuildType<T[K]>;
    		} & {
    			x: 1;
    		}
    	: T;
    
    declare let a: BuildType<{ a: { b: { c: 1 | 2 | 3 } } }>;
    declare let b: BuildType<{ a: { b: { c: 1 | 2 } } }>;
    
    // Both allowed!
    a = b;
    b = a;

    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

  7. jcalz commented on May 3, 2026

    @jcalz
    Contributor

    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:

    type BuildType<T> = ([T] extends [object] ? { [K in keyof T]: BuildType1<T[K]>; } & { x: 1; } : T);
    type BuildType1<T> = ([T] extends [object] ? { [K in keyof T]: BuildType2<T[K]>; } & { x: 1; } : T);
    type BuildType2<T> = ([T] extends [object] ? { [K in keyof T]: BuildType3<T[K]>; } & { x: 1; } : T);
    type BuildType3<T> = ([T] extends [object] ? { [K in keyof T]: BuildType<T[K]>; } & { x: 1; } : T);
    
    type A = BuildType<{ a: { b: { c: 1 | 2 | 3 } } }>
    type B = BuildType<{ a: { b: { c: 1 | 2 } } }>
    declare let a: A;
    declare let b: B;
    a = b; // okay
    b = a; // error

    Playground link to code

  8. carljohnsonnewhampshire-hue commented on May 3, 2026

    @carljohnsonnewhampshire-hue
    Author

    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.

    This is what I mean:

    type BuildType<T> =  [T] extends [object] ? { [K in keyof T]: BuildType2<T[K]>; } & Boilerplate<T> : T;
    type BuildType2<T> = [T] extends [object] ? { [K in keyof T]: BuildType<T[K]>; } & Boilerplate<T> : T;
    
    type Boilerplate<T> = { _keys: keyof T }
    
    declare let a: BuildType<{ a: { b: { c: 1 | 2 | 3 } } }>;
    declare let b: BuildType<{ a: { b: { c: 1 | 2 } } }>;
    
    // Both allowed!
    a = b;

    You know? Why does using Boilerplate<T> trigger this heuristic if it's not itself even recursive?

  9. carljohnsonnewhampshire-hue commented on May 3, 2026

    @carljohnsonnewhampshire-hue
    Author

    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:

    type BuildType<T> = { [K in keyof T]: BuildType<T[K]>; } extends infer O ? O : Never
    
    declare let a: BuildType<{a:{b:{c:{d:{e:{f:{g:{h:{i:{j:{k:{l:1|2|3}}}}}}}}}}}}>;
    declare let b: 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:

    type BuildType<T> = [T] extends [object] ? { [K in 'x' | keyof T]: K extends keyof T ? BuildType<T[K]> : 1 }  : T
    
    declare let a: BuildType<{a:{b:{c:{d:1|2}}}}>;
    declare let b: BuildType<{a:{b:{c:{d:1}}}}>;
    
    // Both suceeed
    a = b;
    b = a;

    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.

  10. jcalz commented on May 3, 2026

    @jcalz
    Contributor

    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

    type BuildType<T> = Invariant<T> & ([T] extends [object] ? { [K in keyof T]: BuildType<T[K]>; } & Boilerplate<T> : T);
    type Invariant<T> = { __phantom?: (x: T) => T }

    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?

  11. carljohnsonnewhampshire-hue commented on May 3, 2026

    @carljohnsonnewhampshire-hue
    Author

    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:

    type BuildType<T> = [T] extends [object]
      ? { [K in keyof T]: BuildType<T[K]> } & {
          _keys: keyof T;
    
          // This fixes it
          __fix: T;
        }
      : T;
    
    declare var a: BuildType<{ a: { b: { c: { d: 1 | 2 } } } }>;
    declare var b: BuildType<{ a: { b: { c: { d: 1 } } } }>;
    
    a = b;
    b = a; // Fails, as expected

    So you were right, making TS care about variance works around this issue!

  12. carljohnsonnewhampshire-hue commented on May 3, 2026

    @carljohnsonnewhampshire-hue
    Author

    The following is even better in that it doesn't create an extra property in nested object types:

    type BuildType<T, _Top = true> = [T] extends [object]
      ? { [K in keyof T]: BuildType<T[K], false> } & {
          _keys: keyof T;
        } & (_Top extends true ? { __fix: T } : unknown)
      : T;
    
    declare var a: BuildType<{
      a: { b: { c: { d: { e: { f: { g: 1 | 2 } } } } } };
    }>;
    declare var b: BuildType<{ a: { b: { c: { d: { e: { f: { g: 1 } } } } } } }>;
    
    a = b;
    b = a; // Fails, as expected
  13. typescript-bot commented on May 6, 2026

    @typescript-bot
    Contributor

    This issue has been marked as "Design Limitation" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions