Typescript intersecting unions stop working after 5 guard properties

40 Views Asked by At

I'm trying to make a class in typescript, where properties only exist if you've checked another property on the class. And I'm trying to make it in a generic way, as we need to re-use a lot of this type guarding for multiple classes.

For example,

class Test {
  FooEnabled: boolean = false;
  Foo() {

  }
}
const test = new Test();
//shouldn't be allowed, because we haven't checked FooEnabled
test.Foo();

//this should be allowed
if(test.FooEnabled) {
  test.Foo();
}

The type essentially should resolve to:


type TTT = {
    other: string,
} & (
    {FooEnabled: false} | {FooEnabled: true, Foo(): void}
) & (
    {BarEnabled: false} | {BarEnabled: true, Bar(): void}
) & (
    {BazEnabled: false} | {BazEnabled: true, Baz(): void}
) & (
    {BayEnabled: false} | {BayEnabled: true, Bay(): void}
// ) & (
//     {BaxEnabled: false} | {BaxEnabled: true, Bax(): void}
)

class Test {
    other = "hello world";
    FooEnabled: boolean = false;
    Foo = () => void 0;
    BarEnabled: boolean = false;
    Bar = () => void 0;
    BazEnabled: boolean = false;
    Baz = () => void 0;
    BayEnabled: boolean = false;
    Bay = () => void 0;
    BaxEnabled: boolean = false;
    Bax = () => void 0;
}
//errors when enabling the `Bax` guard
const value: TTT = new Test();

I have managed to get this working, but only up to 4 different "enabled" properties. And we've been using it for a while, but recently encountered a problem. Having 5 or more of these guards no longer works. It has nothing to do with the contents of what is enabled / disabled, but having 5 or more of these breaks it.

This is the code that I'm using (see playground link below for full code):

type KeysOfUnion<T> = T extends T ? keyof T: never;

type ValueIntersectionByKeyUnion<T, TKey extends keyof T> = {
    [P in TKey]: (k: T[P]) => void
}[TKey] extends ((k: infer I) => void) ? I : never

export type FactoryContent<
    KEY extends keyof FACTORY,
    FACTORY,
    TRUE,
    NOT_TRUE,
> = (
    { [key in KEY]: NOT_TRUE }
) | (
    { [key in KEY]: TRUE } & FACTORY[KEY]
    );
export type InstanceImplements<
    CLASS,
    FACTORY,
    TRUE = true,
    NOT_TRUE = false,
> =
    // Pick the class, excluding the keys that are in the factory
    Pick<
        CLASS,
        Exclude<keyof CLASS, keyof FACTORY | KeysOfUnion<FACTORY[keyof FACTORY]>>
    > & ValueIntersectionByKeyUnion<{
        [key in keyof FACTORY]: FactoryContent<key, FACTORY, TRUE, NOT_TRUE>
    }, keyof FACTORY
    >
    ;

interface TestSupportFactory {
  FooEnabled: {
    Foo(): void;
  },
  BarEnabled: {
    Bar(): void;
  }
}

class Test {
  ...
}
const TEST: InstanceImplements<Test, TestSupportFactory> = new Test();

And this works well, where I can put properties in the "Support factory", with what they hide, and then cannot access them until I check the relevant "enabled" property first.

This works until I put 5 or more properties in the "support factory". Putting 5 or more properties causes an error when trying to cast the class to the InstanceImplements type, where previously it didn't.

So this line will now produce an error:

const TEST: InstanceImplements<Test, TestSupportFactory> = new Test();

I have a playground here where you can see this behaviour, and all the code I used to get this working to this extent.

I'm not sure why this happens. My best guess is some limit in typescript to do with unions?

1

There are 1 best solutions below

0
jcalz On

The problem is that you're using (or maybe abusing) the support for "smarter" union type checking implemented in microsoft/TypeScript#30779 by assigning a single class instance type to a discriminated union type, and running into a hardcoded limit of 25 union members.

In your case, it looks like if TestSupportFactory has properties, then InstanceImplements<Test, TestSupportFactory> will be a union of ² members. As soon as > 5 you will have problems.


Before TypeScript 3.5 it was completely impossible to do this, and even "obviously" correct things like

const x: { v: true } | { v: false } = { v: Math.random() < 0.5 };

would cause an error. There was no way to relate a single object type with union-typed properties to a union of object types.

Now, when faced with an assignment like this, the compiler attempts to "split" the single type (above, that's {v: true | false}) into a union so that it can be compared. So it turns {v: boolean} into {v: true} | {v: false} and everything's fine.

But automatically generating unions from single types has the potential to be catastrophically bad for compiler performance. Each additional property whose type is an -member union has the effect of multiplying the number of members in the resulting union by . If you have, say, 10 boolean properties, now you have 2¹⁰ = 1,024 union members. Add another two such properties and now you have 2¹² = 4,096 members. And comparing that with a target discriminated union will take effort on the order of the product of the size of the two unions.

Any compiler feature that has a tendency to create exponential amounts of work for incremental changes in the code is going to be a problem. To prevent that from happening, the union type checking support in microsoft/TypeScript#30779 has a hardcoded limit of 25 union members:

If the number of combinations is above a fixed limit (currently 25), we consider the comparison too complex and determine that 'source' is not related to the 'target'.

So that's what's going on.


Perhaps this limit is more conservative than necessary and could be increased, but it's unlikely that anyone will want to do this to support your use case, since all it would do is place the boundary a little farther out. You'd run into it for, say, 10 properties instead of 6.

Instead you should probably rewrite your code so as not to make large discriminated unions programmatically, or at least not to assign single object types to them. There are lots of possible approaches though and it's out of scope for this question to delve into any of them.