Search code examples
typescript

Typescript type detection inaccurate - why?


In some cases the typescript compiler doesn't detect the types correct which requires me to be redundant. In my head this types are absolutly safe declared. Do I miss something or is the compiler incorrect? And is there any way I can help him autodetect the types correct without repeating myself?

interface Dto {
    value1: boolean;
    value2: string;
    value3: string;
}

class Model {
    firstValue!: boolean;
    secondValue!: number;
    thirdValue!: string;

    parse<
        dtoName extends keyof Dto,
    >(entry: dtoName, raw: Dto[dtoName]): void {
        switch(entry) {
            case 'value1':
                /* raw is save a boolean isn't it? Its declared as …
                      Dto[dtoName]
                    = Dto['value1']
                    = boolean
                */
                this.firstValue = raw;
                break;

            case 'value2':
                this.secondValue = Number(raw);
                break;

            case 'value3':
                this.thirdValue = raw; // same here
                break;

            default:
                throw new Error(`Unknown entry ${entry}`);
        }
    }
}

Link to Playground


Solution

  • TypeScript is currently unable to use control flow analysis to affect generic type parameters like K (changed from dtoName which is an unconventionally named type parameter; at the very least it should be DtoName, but K is even more conventional for a keylike type parameter). So when you check entry, TypeScript can narrow entry from K to, say, "value1", but K itself remains unchanged. And that means raw of type Dto[K] is also unchanged. There are various open feature requests, like microsoft/TypeScript#33014, asking for something better here, but for now it's not possible.

    One major stumbling block toward implementing that is that, as written, K can itself be a union type. It might not be "value1", "value2", or "value3". It could also be, say, "value1" | "value2". And that means Dto[K] could be boolean | string. So nothing prevents a call like the following:

    new Model().parse(
        Math.random() < 0.99 ? "value1" : "value2",
        "oops"
    );
    

    That is accepted by TypeScript, yet there is a 99% chance that you receive an input your implementation doesn't expect. So the compiler error is technically correct; it really is true that entry might be "value1" while raw might be of type string instead of boolean. So part of getting better language support for the kind of generic code you're writing would involve some way to say "K can't be a union", such as the feature request at microsoft/TypeScript#27808. And again, for now, it's not part of the language.


    So you'll need to work around it. Either you give up on control flow analysis (such as switch/case) or you give up on generics or you use something like type assertions and give up on compiler-verified type safety.

    In your case, you can actually give up on generics without losing anything. The return type of parse() is void and does not depend on the inputs. That means the function doesn't need to be generic. Instead of having your parameters be generic, you can the function take a tuple-typed rest parameter, like this:

    parse(...[entry, raw]:
        [entry: "value1", raw: boolean] |
        [entry: "value2", raw: string] |
        [entry: "value3", raw: string]
    ): void {
        switch (entry) {
            case 'value1':
                this.firstValue = raw; // okay
                break;
    
            case 'value2':
                this.secondValue = Number(raw);
                break;
    
            case 'value3':
                this.thirdValue = raw; // okay
                break;
    
            default:
                throw new Error(`Unknown entry ${entry}`);
        }
    }
    

    That union-of-tuples is a discriminated union where the first element can be used as a discriminant to narrow the type of the second element. The entry and raw variables are destructured from the rest parameter, and TypeScript supports control flow analysis for destructured discriminated unions. It might look weird for the function to take (...[entry, raw]) instead of (entry, raw), but it's equivalent, and this approach has the advantage of actually working for you. So now when you check entry, TypeScript can narrow raw accordingly.

    And now the problematic call from before is disallowed:

    new Model().parse(
        Math.random() < 0.99 ? "value1" : "value2",
        "oops"
    ); // error!
    

    Because ["value1" | "value2", string] matches none of the three union members.


    That's the answer to the question as asked, although it's tedious and redundant to have to write out that union of tuples yourself. Luckily you can compute it from Dto as follows:

    type Args = { [K in keyof Dto]: [entry: K, raw: Dto[K]] }[keyof Dto]
    

    That's a distributive object type as coined in microsoft/TypeScript#47109, which is a mapped type into which you immediately index to get the union of the mapped type's properties. If you have a type function F<K> you'd like to distribute over unions in K, you can write {[P in K]: F<P>}[K]. So the above becomes the union of [entry: K, raw: Dto[K]] for each K in keyof Dto.

    Armed with that, you can make parse()'s rest parameter be of type Args:

    parse(...[entry, raw]: Args): void { }
    

    and everything still works.

    Playground link to code