Search code examples
typescriptconditional-types

Generic function with argument based on conditional type in TypeScript 5


I have a type with some different config objects, like this:

type Types =
  | { kind: 'typeA', arg1: string }
  | { kind: 'typeB', arg1: string, arg2: string }

I also have type which pulls out just the kind subtype from the union above:

type InnerType = Types['kind'];

Here innerType is a union of 'typeA'|'typeb' as expected.

Lastly I have conditional type which extracts only the non-kind subtypes also from the Types union:

type ExtractOtherParams<K, T> = K extends { kind: T }
  ? Omit<K, 'kind'>
  : never

So far, this works as expected - if I create a new type called Test and use the conditional type passing in typeA then the type is an object containing only arg

type Test = ExtractOtherParams<Types, 'typeA'> // test = { arg1: string }

And if I pass typeb to the conditional, then it gives object type with arg1 and arg2 properties, e.g:

type Test = ExtractOtherParams<Types, 'typeB'> // test = { arg1: string, arg2: string }

So far, so good. But now when I try to define function which uses this conditional then it does not work as expected. For example:

function test<T extends InnerType>(
  kind: T,
  others: ExtractOtherParams<Types, T>,
): void {
switch (kind) {
  case 'typeA':
    console.log(others.arg1);
    break;
  case 'typeB':
    console.log(others.arg1, others.arg2);
}

Here it is mostly correct - I can only switch on the values 'typeA' or 'typeB', any other value not in the original Types union gives error - this is good. But inside second case, it is giving error when I try to access others.arg2 (it says arg2 does not exist on type 'ExtractOtherParams<{ kind: "typeA"; arg1: string; }, T>')

But, when I use this function, it works as expected from a consumer POV, for example, if I call the function with typeA then it will only let me pass object with arg1:

test('typeA', { arg1: 'test' }); // correct, no errors
test('typeA', { arg1: 'test', arg2: 'oops' }); // correct, has error on arg2
test('typeB', { arg1: 'test', arg2: 'works' }); // correct, no errors
test('typeB', { arg1: 'test' }); // correct, has error for missing arg2

What am I missing from the definition of test function to allow it to access the arg2 inside the second case expression?

I tried function definition without extending, just using generic type, e.g:

test<T> But this did not make a difference. Unsure what else to try tbh


Solution

  • The problem is that currently TypeScript does not use control flow analysis to affect generic type parameters. So if you check kind, you might be able to narrow the apparent type of kind from T to, say, T & "typeB". But T itself doesn't change. And therefore ExtractOtherParams<Types, T> cannot be equated with, say, ExtractOtherParams<Types, "typeB">. And so you get that error.

    There are various open issues about this; the one that seems to be canonical for your use case is microsoft/TypeScript#33014. It's been open for quite a while, but there's some hope it might get addressed sometime soon, as there's a pull request at microsoft/TypeScript#56941 for it. But until and unless it's actually merged, we have to work around your problem.


    An easy workaround is to just a type assertion to tell TypeScript that others in the second case is of the expected type:

    function test<T extends InnerType>(
        kind: T,
        others: ExtractOtherParams<Types, T>,
    ): void {
        switch (kind) {
            case 'typeA':
                console.log(others.arg1);
                break;
            case 'typeB':
                console.log(
                    others.arg1,
                    (others as ExtractOtherParams<Types, "typeB">).arg2
                );
        }
    }
    

    More complicated is a refactoring away from using generics so that control flow analysis is helpful. The sort of narrowing you're trying to use in the function treats kind and others as if they were both properties of a discriminated union. You can actually write test() so it uses a destructured discriminated union rest parameter:

    type TestArgs = Types extends infer T ? T extends Types ?
        [kind: T['kind'], others: Omit<T, "kind">] : never : never
    
    /* type TestArgs = 
        [kind: "typeA", others: { arg1: string; }] | 
        [kind: "typeB", others: { arg1: string; arg2: string; }] 
    */
    
    function test(...[kind, others]: TestArgs): void {
        switch (kind) {
            case 'typeA':
                console.log(others.arg1);
                break;
            case 'typeB':
                console.log(others.arg1, others.arg2); // okay
        }
    }
    
    test('typeA', { arg1: 'test' }); // correct, no errors
    test('typeA', { arg1: 'test', arg2: 'oops' }); // correct, has error on arg2
    test('typeB', { arg1: 'test', arg2: 'works' }); // correct, no errors
    test('typeB', { arg1: 'test' }); // correct, has error for missing arg2
    

    The TestArgs type was created via distributive conditional type over Types, to get the two different intended call signatures for test(). Then the call signature looks like (...[kind, others]: TestArgs) => void, meaning that either [kind, others] is of type [kind: "typeA", others: { arg1: string; }], or it is of type [kind: "typeB", others: { arg1: string; arg2: string; }].

    And now if you check kind, the compiler understand the implication on others. And your calls still see the expected type checking.

    Playground link to code