typescript

Overload functions does not infer the proper declaration signature in definition


I would expect that a function's overload declarations will match the types in the definition (in implementation).

Why args results in an error?

type UserFindMany = { a: boolean }
type WorkflowFindMany = { b: boolean }
type PostFindMany = { c: boolean }

type ORM = {
    user: {
        findMany(args: UserFindMany): { a: string };
    };
    workflow: {
        findMany(args: WorkflowFindMany): { b: string };
    };
}

const orm: ORM = {
    user: {
        findMany(args) { return { a: 'a' } },
    },
    workflow: {
        findMany(args) { return { b: 'b' } },
    },
}


type ORMKeys = keyof ORM;

function get<const TModel extends 'user'>(model: TModel, args: UserFindMany): { a: string };
function get<const TModel extends 'workflow'>(model: TModel, args: WorkflowFindMany): { b: string };
function get<const TModel extends ORMKeys>(model: TModel, args: UserFindMany | WorkflowFindMany): { a: string } | { b: string } {
    return orm[model].findMany(args); // error!
    // Argument of type 'UserFindMany | WorkflowFindMany' is not 
    // assignable to parameter of type 'UserFindMany & WorkflowFindMany'.
}

const a = get('user', { a: true }); // is not an error
const b = get('user', { b: true }); // is an error

Playground


Solution

  • Function overloads in Typescript essentially split the behavior of the function into two distinct sides. There is the call side, represented by the call signatures, and the implementation side, represented by the implementation (if it even exists). The two sides are only barely connected to each other, and the type checking there is very loose. Your function is essentially this:

    // call signatures
    function get(model: 'user', args: UserFindMany): { a: string };
    function get(model: 'workflow', args: WorkflowFindMany): { b: string };
    
    // implementation
    function get(model: ORMKeys, args: UserFindMany | WorkflowFindMany): { a: string } | { b: string } {
        return orm[model].findMany(args); // error!
        // Argument of type 'UserFindMany | WorkflowFindMany' is not 
        // assignable to parameter of type 'UserFindMany & WorkflowFindMany'.
    }
    

    The implementation is "compatible" with the call signatures in the sense that it will accepts as arguments anything accepted by the call signatures, and the return type of each call signature is reflected somewhere in the implementation return type. But other than that basic compatibility check, TypeScript does not and cannot check the implementation against each call signature separately.

    Inside the implementation, model can be either "user" or "workflow" and args can be either UserFindMany or WorkflowFindMay, and these unions are not correlated to each other. According to its signature, you could call the implementation with mismatching inputs like get('user', { b: true }). So there's an error, because orm[model].findMany and args might be mismatched. Yes, this turns out to be impossible from the outside, but the only way TS would know that is if it were to check the implementation separately against each call signature, but again, TypeScript does not and cannot do that.

    It would be prohibitively expensive for the compiler to check a given function body multiple times, and even though in toy examples like this it would only mean checking something twice instead of once, in real world code bases this would involve combinatorial explosions of checking (imagine a recursive overloaded function, or an overloaded function that calls another overloaded function, etc). Requests for this sort of thing have been consistently declined, because they add a bunch of type checking work for a problem that just doesn't come up enough for it to be worth trying. See this comment on microsoft/TypeScript#13225 and other issues in GitHub about overload checking.

    For better or worse, there's a fairly strong barrier between overload function call signatures and their implementations, and this is unlikely to change.


    The sort of thing you're doing, where unions are correlated, so that the pair [model, args] can only ever be either ["user", UserFindMany] or ["workflow", WorkflowFindMany], is discussed in microsoft/TypeScript#30581. The recommended approach is not to use overloads, but to refactor to a particular form of generics as described in microsoft/TypeScript#47109.

    The idea is to find "base" interfaces representing the most basic relationship between the inputs and outputs, and then write all operations in terms of those interfaces, or in terms of mapped types on those interfaces, and in terms of generic indexes into those types. The goal is that orm[model].findMany will be seen as a generic single function and not a union of functions, and that its parameter will be the same generic type as args. You can read ms/TS#47109 for more details on how and why this works.

    For your example code, the approach looks like this. The base interfaces are

    interface ORMArg {
        user: UserFindMany,
        workflow: WorkflowFindMany
    }
    
    interface ORMRet {
        user: { a: string };
        workflow: { b: string }
    }
    

    And now ORM can be written as a mapped type over ORMArg:

    type ORM = { [K in keyof ORMArg]: { findMany(args: ORMArg[K]): ORMRet[K] } }
    
    const orm: ORM = {
        user: {
            findMany(args) { return { a: 'a' } },
        },
        workflow: {
            findMany(args) { return { b: 'b' } },
        },
    }
    

    Finally, get() is generic, where model is of generic type K and args is of the type ORMArg[K]:

    function get<K extends keyof ORMArg>(model: K, args: ORMArg[K]): ORMRet[K] {
        return orm[model].findMany(args); // okay
    }
    

    That compiles because orm[model] is seen to be of the single generic type { findMany(args: ORMArg[K]): ORMRet[K] }. so orm[model].findMany accepts ORMArg[K], which is what args is, hooray! And it returns ORMRet[K] as desired.

    Your function can still only be called correctly (at least by default) and mismatched inputs still raise errors:

    const a = get('user', { a: true }); // is not an error
    const b = get('user', { b: true }); // is an error
    

    Yes, such refactoring is a bit complicated and possibly seems unmotivated, since the types are "equivalent" without it. But it's necessary if you want TypeScript to follow the logic. If you don't care about TypeScript following the logic then you can just start using type assertions to silence errors, or write redundant code, or any of the other possibilities for correlated unions as mentioned in ms/TS#30581 above.

    But overloads don't do what you want here, for the reasons laid out above.

    Playground link to code