Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Type constraints for overloaded function in TypeScript

Tags:

typescript

So I can overload functions:

function myFunc(x : number) : number
function myFunc(x : string) : string
function myFunc(x : number | string) : number | string {
  if (typeof x == "string") {
    return x + "1"
  } else {
    return x + 1
  }
}

And it works:

const x = myFunc(1)   // correctly inferred as number
const y = myFunc("1") // correctly inferred as string

This syntax hovewer does not protect from mixed types in overloaded implementation:

function myFunc(x : number) : number
function myFunc(x : string) : string
function myFunc(x : number | string) : number | string {
  if (typeof x == "string") {
    return 1 // !!! no type error
  } else {
    return "1" // !!! no type error
  }
}

If I add generics, I get errors even for the "correct" version:

function myFunc(x : number) : number
function myFunc(x : string) : string
function myFunc<T extends number | string>(x : T) : T {
  if (typeof x == "string") {
    return "1" // !!! ERROR
  } else {
    return 1 // !!! ERROR
  }
}

I get the good old:

TS2322: Type 'number' is not assignable to type 'T'.   'number' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | number'.

for both branches. Basically it's the same as having just

function myFunc<T extends number | string>(x : T) : T {
  if (typeof x == "string") {
    return "1" // !!! could be instantiated with a different subtype... ERROR
  } else {
    return 1 // !!! could be instantiated with a different subtype... ERROR
  }
}

Is there a way to constraint input-output types in overloaded functions such that it won't have the above limitations? It looks like a pretty common case but somehow I can't find the answer in Google.

like image 752
Ivan Kleshnin Avatar asked Mar 02 '23 14:03

Ivan Kleshnin


1 Answers

Summary: This is the result of a few design limitations or missing features in TypeScript. Overloads are intentionally unsound. You can try to work around this to get more strict type checking, but it's ugly and not worth it (in my opinion). Generics don't help and have their own drawbacks. Generally speaking, the most expedient thing to do is to just be careful with your implementation and move on.


For better or worse, overload implementation signatures are intentionally allowed to be looser than the intersection of all the call signatures.

Roughly, the return type of the implementation is allowed to be union of the return types of all the call signatures, even though this ignores any relationship between the input and output for any particular call signature. As long as the implementation of myFunc() accepts a parameter of type number | string and returns a value of type number | string, the compiler is happy... even if the implementation returns a number in cases where the relevant call signature declares that it returns a string. This is one of those places where TypeScript's type system intentionally fails to be sound.

There was a feature request at microsoft/TypeScript#13235 asking for function implementations to be strictly checked against each call signature. When it was discussed by the TypeScript team, they determined that such a feature would scale badly (something like n2 in the number of call signatures) and people make such mistakes with overload implementations too rarely for it to be worth the extra compiler time. The feature was closed as "Too Complex" and later such requests have been declined.

So the compiler will not automatically help detect incorrect overload implementations.


One possible workaround which gives you more of a guarantee is to try to use the results of control flow analysis to check that the value you are returning is the one corresponding to the correct call signature. This works for your particular example function... but it doesn't always work (see the generics section later). And even if it works, it's ugly and has some unavoidable (but minor) runtime effects:

function myFunc(x: number): number
function myFunc(x: string): string
function myFunc(x: number | string): number | string {
    if (typeof x == "string") {
        const ret = x + "1";
        let test: typeof ret = (false as true) && myFunc(x) // okay
        return ret;
    } else {
        const ret = x + 1;
        let test: typeof ret = (false as true) && myFunc(x) // okay
        return ret;
    }
}

Here, I save the intended return value into a variable named ret, and then force the compiler to pretend that it is calling myFunc(x) (the (false as true) && ... bit makes the compiler think that the stuff after && is actually running, even though at runtime it won't, which is good, because we don't want to actually do anything). If the compiler is happy assigning the supposed result of myFunc(x) to a variable whose type is the same as ret, then all is well. If you make a mistake, you'll get warnings:

function myFunc(x: number): number
function myFunc(x: string): string
function myFunc(x: number | string): number | string {
    if (typeof x == "string") {
        const ret = 1;
        let test: typeof ret = (false as true) && myFunc(x) // error
        // Type 'string' is not assignable to type '1'.
        return ret;
    } else {
        const ret = "1";
        let test: typeof ret = (false as true) && myFunc(x) // error
        // Type 'number' is not assignable to type '"1"'
        return ret;
    }
}

So that works, but personally I wouldn't do this unless the consequence of getting the implementation wrong were incredibly dire.


As for the part about the generic version of the function... first of all, the call signature needs to be something like this:

declare function myFunc<T extends number | string>(
  x: T): T extends number ? number : string;

You can't return T, because literal types like "hello" and 123 exist, and you don't want to claim that myFunc(123) returns 123, just number. But anyway, even with that correct version, the compiler will give you the same errors:

function myFunc<T extends number | string>(x: T): T extends number ? number : string {
    if (typeof x == "string") {
        return x + "1"; // error!
        // Type 'string' is not assignable to type 'T extends number ? number : string'.
    } else {
        return x + 1; // error!
    }
}

This is another missing feature of TypeScript; the compiler has no way to verify that a particular value (like x + "1") is assignable to a conditional type that depends on unspecified generic type parameters. The compiler just defers evaluating such types when T is not yet resolved, and so T extends number ? number : string is too opaque for the compiler to see if x + "1" is or is not a value of that type.

The canonical issue about this is probably microsoft/TypeScript#33912, which asks for some support for implementing functions whose return type is just such an unresolved conditional type. Nothing has been done there yet, and again, it's a hard problem to solve.

For now, such functions will tend to give all kinds of compiler warnings unless you use type assertions at each return:

function myFunc<T extends number | string>(x: T): T extends number ? number : string {
    if (typeof x == "string") {
        return x + "1" as any
    } else {
        return x + 1 as any
    }
}

Actually, I generally deal with this sort of thing by switching the implementation to an overload (so the call signature is generic, and the implementation signature is not):

function myFunc<T extends number | string>(x: T): T extends number ? number : string;
function myFunc(x: number | string) {
    if (typeof x == "string") {
        return x + "1";
    } else {
        return x + 1;
    }
}

which, perhaps ironically in this context, prevents compiler errors by relying on the unsoundness of overload implementations that inspired this question in the first place.

Oh well!


My recommendation is just to be careful with your overload implementations, convince yourself that they are type safe, and then move on. It's by far the least painful of the solutions I can think of, even though it is not very satisfying for those who care about type safety.

Playground link to code

like image 76
jcalz Avatar answered May 12 '23 15:05

jcalz