Search code examples
typescriptoverloading

Typescript overload for switch case


Let's consider this code

interface First {
    a: number
}

interface Second {
    b: number
}

type GeneralInterface = First | Second;
type Kind = 'First' | 'Second';

function getValue(kind: 'First', object: First): number
function getValue(kind: 'Second', object: Second): number
function getValue(kind: Kind, object: GeneralInterface): number {
    switch (kind) {
        case "First": {
            return object.a
        }
        case "Second": {
            return object.b
        }
    }
}

I see error

TS2339: Property 'a' does not exist on type 'GeneralInterface'.   Property 'a' does not exist on type 'Second'.

To make it working i have to write this way

function getValue(kind: 'First', object: First): number;
function getValue(kind: 'Second', object: Second): number;
function getValue(kind: Kind, object: GeneralInterface): number {
    if (kind === 'First' && 'a' in object) {
        return object.a;
    } else if (kind === 'Second' && 'b' in object) {
        return object.b;
    }
    throw new Error(`Invalid argument: ${kind}`);
}

but these additional checks makes code less readable. For more complicated interfaces it require a lot of work. Is there any workaround?

I am sure that there will be no other combinations of arguments that these:

function getValue(kind: 'First', object: First): number
function getValue(kind: 'Second', object: Second): number

Solution

  • Unfortunately TypeScript can't analyze the implementation of overloaded functions the way you want. The compiler only "sees" the implementation signature, which you've got annotated as

    function getValue(kind: Kind, object: GeneralInterface): number {...}
    

    so there's no correlation between kind and object that the compiler can use to narrow object when you check kind.

    There is a longstanding open issue at microsoft/TypeScript#22609 asking for the compiler to use the call signatures for control flow purposes inside the implementation. Until and unless this is implemented, you'll have to look for another solution.


    Because both of your call signatures return number, there's an alternative approach which looks very much like an overloaded function from the caller's side, but which the implementation can actually verify the way you'd like:

    type KindObject =
        [kind: "First", object: First] |
        [kind: "Second", object: Second]
    
    function getValue(...[kind, object]: KindObject): number {
        switch (kind) {
            case "First": {
                return object.a // okay
            }
            case "Second": {
                return object.b // okay
            }
        }
    }
    

    This is a single call signature with a destructured rest parameter ...[kind, object], whose type is KindObject, a discriminated union of tuple types. TypeScript 4.6 and above is able to perform control flow analysis narrowing in such cases.


    And let's just test it from the call side:

    // 1/2 getValue(kind: "First", object: First): number
    // 2/2 getValue(kind: "Second", object: Second): number
    getValue("First", { a: 1 }); // okay
    getValue("First", { b: 2 }); // error
    getValue("Second", { b: 2 }); // okay
    getValue("Second", { a: 1 }); // error
    

    Looks good! The IntelliSense shows the same set of call signatures you'd see for an overloaded function, and the compiler only accepts calls corresponding to the two intended call signatures and rejects ones with mismatching arguments.

    Playground link to code