It's possible to overload class methods in TypeScript by declaring each method, and then adding a single implementation that fulfills the contracts of all the declared overloaded methods, typically by using union types. This is great because it enables overloading while still hiding the implementation details from the calling code - the implementation with the union types is kept hidden from the callers.
However, it seems that from the implemented method's perspective, the TypeScript compiler does not always infer the types of arguments properly in an "OK if it's not overload #1 and not overload #2 then it must be overload #3" kind of way, like it usually does for union types. Consider the following:
class Foo {
// API has three overloaded 'foo' methods
public foo(age: number): void;
public foo(name: string): void;
public foo(foo: Foo, name: string): void;
// Implementation with union types to accomodate for all three overloads
// Here, 'name' must be optional (string | undefined) to fit overloads #1 and #2,
// but if 'x' is a 'Foo', 'name' must be a string
public foo(x: number | string | Foo, name?: string): void {
if (typeof x === 'number') {
const age = x;
console.log(`You called overload #1, I got age ${age}`);
} else if (typeof x === 'string') {
const name = x;
console.log(`You called overload #2, I got name ${name}`);
} else {
// You would think that the compiler now understands that 'name' must be a string, but
// it still considers it to be string | undefined.
//console.log(name.substring(0)); // fails with error TS2532: Object is possibly 'undefined'
// So we need another, seemingly pointless, check for the type of 'name':
if (typeof name === 'string') {
const foo = x;
console.log(`You called overload #3, I got foo ${foo} and name ${name}`);
} else {
// This shouldn't happen, it fits none of the overloaded methods, so the caller must
// have simply abused the interface
console.log(`Oh noes I got ${x} and ${name}`);
}
}
}
toString() {
return 'Foo';
}
}
new Foo().foo(42);
new Foo().foo('John Doe');
new Foo().foo(new Foo(), 'John Doe');
new Foo().foo(new Foo(), undefined as any); // Uh-oh!
How come, in the else branch at line 19, the compiler doesn't realize that name must be of type string rather than string | undefined?
I mean, of course you can always abuse an interface by using undefined as any etc, to provoke runtime errors by pretending to pass a string while actually passing undefined. But that's true with any TS code. To me it seems like the compiler should be able to figure out that if we're not in overload #1 and not in overload #2, then we must be in overload #3, where name is in fact of type string .
Interestingly enough, if the name.substring(0) code is uncommented, calling tsc foo.ts with tsc version 4.1.4 succeeds (but in runtime, node foo.js will of course throw an error on the last line where undefined as any is passed), while with ts-node 9.1.1, you get a compile error:
$ npx ts-node foo.ts
⨯ Unable to compile TypeScript:
foo.ts:22:19 - error TS2532: Object is possibly 'undefined'.
22 console.log(name.substring(0)); // fails with error TS2532: Object is possibly 'undefined'
~~~~
Is this simply a shortcoming in the compiler's handling of overloads or is there something I should be doing differently? Also, what is the reason for the difference in behaviour between tsc and ts-node? I would guess that tsc-node simply transpiles the code just-in-time and then runs the transpiled code, but it actually produces a compile error, not a runtime error, while tsc does not.
PS. If there's anything I'd wish for TS 5 it would be to enable writing separate overload implementations and not having to care about an obscure union type implementation, but let the compiler add the logic to determine which overload is being called automatically! :)