typescripttypescript-generics

How can I extend a discriminated union type using a generic type parameter?


I have a library which follows the typescript idiom of defining a type as a union of object types. Each of the objects has a type field with a different discriminator value. Lets say it is defined as

type Core = { type: 'core-1' } | { type: 'core-2' }

Code that uses the library can add its own object types to the union. I'm trying to model the most general way in which this could happen by defining a type AnyTypedObject which is intended to mean any object type that has a string valued type field. So we say

type AnyTypedObject = { type: string; } & { [key: string]: any; }

I'd like to say that the type field cannot be one of the values used in the library, but my understanding is that this cannot be done through typescript.

Within the library I want to define generic functions which are aware of the possibility that the object type has extra fields. They take a generic type parameter B to specify the extension. E.g we could write

type TypedObject<B extends AnyTypedObject> = B | Core
type Types<B extends AnyTypedObject> = TypedObject<B>['type']

The intent is that Types<B> is a union of all of the potential object types, both the objects from Core or those in the extension B.

I can then use Types<B> in other definitions. For example we could define an object type which only has the discriminator field and nothing else.

type PureType<B extends AnyTypedObject, K extends Types<B> = Types<B>> = { type: K; }

Now I want to define a function that takes as input a pure type object. Here we are just returning the input to illustrate the problem.

function foo<B extends AnyTypedObject, K extends Types<B> = Types<B>>(
    input: PureType<B, K>,
) {
    return input;
}

The issue is that typescript is giving me an error with this function definition. It states that

Type 'K' does not satisfy the constraint 'Types'.

Type 'Types' is not assignable to type 'never'.

Type '"core-1"' is not assignable to type 'never'

Once I provide an explicit value for B it works as expected. We can do the following:

type Extension = { type: 'extension' } | { type: 'other' };
foo<Extension>({ type: 'core-1' });       // okay
foo<Extension>({ type: 'core-2' });       // okay
foo<Extension>({ type: 'extension' });    // okay
foo<Extension>({ type: 'other' });        // okay
foo<Extension>({ type: 'invalid' });      // fails as intended

I experimented with changing the definition of Core. If it contains an empty union then there is no error. If it contains exactly one object type then the error message changes. It looks like TS is inferring that K must be the intersection of all the discriminator values instead of the union. So as soon as two explicit values are available it infers that K must be never.

My question: Why does TS reject the definition of foo()? I know that the example shown here does not make much sense - I cut it down from a much larger project.

TS Playground containing all code from this question


Solution

  • It's considered a bug in TypeScript, as described in microsoft/TypeScript#60892. Apparently the type checker gets confused about whether the indexed access type TypedObject<B>['type'] is being read from (source) or written to (target), and due to the work at microsoft/TypeScript#30769, it's changed from a union to an intersection. That behavior is a safety/soundness improvement, in that it catches things which are actual errors (e.g., if you have an object o of type {a: A, b: B} and the key k of type "a" | "b", then if you read o[k] then it's A | B, but if you write o[k] you cannot accept A | B and be safe. It needs to be A & B), but it also catches things which are not errors.

    Anyway, microsoft/TypeScript#60892 is labeled as Help Wanted, meaning that they will entertain pull requests from the community. So anyone who wants to see this fixed might consider doing it themselves. Until and unless the bug is fixed, you'll need to work around it. The easiest workaround for the code as written is to explicitly evaluate Types<B> yourself as a union of indexed access types, instead of expecting the compiler to do it:

    type Types<B extends AnyTypedObject> = B['type'] | Core['type']
    

    If you can't rewrite the type, then the only thing I've found that works is to just suppress the error with a //@ts-ignore comment:

    function foo<B extends AnyTypedObject, K extends Types<B> = Types<B>>(
        //@ts-ignore
        input: PureType<B, K>,
    ) { return input; }
    

    It doesn't look like suppressing the error has any negative effects, but I would stay away from this except as a last resort. All //@ts-ignore does is prevent the error from being displayed; it doesn't actually resolve it. So any knock-on effects of that error will still be present.

    Playground link to code