Home > Blockchain >  Overloaded Function that can take two objects or a consolidated object
Overloaded Function that can take two objects or a consolidated object

Time:10-03

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.

Playground link to code

  • Related