I have an interface, let's call it Worker
. It looks something like this.
interface Worker {
doWork(key: string): number;
};
A Worker
takes a key value and does something with it. Now I want a function that calls doWork
indirectly, i.e. something like
function doWork(key: string, worker: Worker): number {
// Pretend there's more complicated stuff happening around it :)
return worker.doWork(key);
}
This is fine in most cases. However, there are some Worker
instances that only make sense for a particular key
value. In those cases, I want my function to be able to deduce the key
value, i.e.
function doWork(worker: Worker & { key: string }): number {
// Pretend there's more complicated stuff happening around it :)
return worker.doWork(worker.key);
}
so my thought was to combine these two functions into one using Typescript overload signatures, so that you can call this function with one or two arguments.
function doWork(key: string, worker: Worker): number;
function doWork(worker: Worker & { key: string }): number;
function doWork(arg0: string | (Worker & { key: string }), arg1?: Worker): number {
if (typeof arg0 === 'string') {
const key: string = arg0;
const worker: Worker = arg1!; // non-null assertion
return worker.doWork(key);
} else {
const key: string = arg0.key;
const worker: Worker = arg0;
return worker.doWork(key);
}
}
This works, except I've got a messy non-null (well, non-undefined) assertion on Line 6. Since the function doWork
can only be called with either (1) a string
and a Worker
, or (2) a single Worker
which has a key, it seems to me that, once I check that typeof arg0 === 'string'
, Typescript should be able to reason that we're in the first case and thus that arg1
is not undefined. But if we remove the !
,
function doWork(key: string, worker: Worker): number;
function doWork(worker: Worker & { key: string }): number;
function doWork(arg0: string | (Worker & { key: string }), arg1?: Worker): number {
if (typeof arg0 === 'string') {
const key: string = arg0;
const worker: Worker = arg1; // Oops!
return worker.doWork(key);
} else {
const key: string = arg0.key;
const worker: Worker = arg0;
return worker.doWork(key);
}
}
we get an error.
$ tsc --strict overload.ts
overload.ts:11:11 - error TS2322: Type 'Worker | undefined' is not assignable to type 'Worker'.
Type 'undefined' is not assignable to type 'Worker'.
11 const worker: Worker = arg1;
~~~~~~
Found 1 error.
Is there a type-safe way to write this function, that doesn't involve circumventing Typescript (unchecked downcasting, any
, etc.)? Can I prove to Typescript that my function implementation handles the two overloads like it should?
CodePudding user response:
Overloaded functions are split into a set of call signatures that callers see, and a single implementation with a signature that the implementation sees. These are compared loosely with each other, but that's about it. The compiler does not use the call signatures to narrow types inside the implementation; they're fairly separate. There have been a number of feature requests around improving this: see microsoft/TypeScript#14515 and microsoft/TypeScript#22609, among others probably. So far there is no support in the language for it.
And according to the implementation signature, arg0
is of type string | (Worker & { key: string })
while arg1
is of type Worker | undefined
. Each argument is therefore of its own separate and uncorrelated union type, and checking arg0
has no implication for arg1
.
Instead of trying to use "true" overloads for this, you could get similar behavior by having a single function signature that takes a union of rest tuples:
function doWork(
...args: [key: string, worker: Worker] | [worker: Worker & { key: string }]
): number {
if (args.length === 2) {
const key: string = args[0]; // okay
const worker: Worker = args[1]; // okay
return worker.doWork(key);
} else {
const key: string = args[0].key; // okay
const worker: Worker = args[0]; // okay
return worker.doWork(key);
}
}
Such a replacement is possible because both of your original call signatures had the same return type (number
). These still look very much like overloads from the caller's point of view:
// 1/2 doWork(key: string, worker: Worker): number
doWork("xyz", { doWork(k) { return k.length } }); // okay
// 2/2 doWork(worker: Worker & { key: string; }): number
doWork({ key: "xyz", doWork(k) { return k.length } }); // okay
But the compiler can now verify that the implementation body is safe, via control flow analysis.
Note that if we had kept your original typeof
check, the compiler still would complain about worker
being possibly undefined
:
if (typeof args[0] === "string") {
const key: string = args[0];
const worker: Worker = args[1]; // error! still Worker | undefined
return worker.doWork(key);
}
Even though the check should logically distinguish whether args
is of type [string, Worker]
or of type [{key: string} & Worker}]
, the compiler cannot see this. The language does not support discriminating a union by a non-literal type type such as string
.
But the length
property of a tuple is of a numeric literal type; so length
acts as a proper discriminant, and checking args.length === 2
allows the compiler to distinguish which form of call was made, and everything works with no compiler error.