typescriptoverloading

TypeScript brainteaser: returning an overloaded function


In TypeScript, I can easily create a function with overloads that returns a different return type depending on the input. E.g.,:

function doSomething(mode: "ReturnPromise"): Promise<any>;
function doSomething(mode: "FireAndForget"): void;
function doSomething(mode: "ReturnPromise" | "FireAndForget"): Promise<any> | void {
  if (mode == "ReturnPromise") {
    console.log("Will return a promise")
    return Promise.resolve();
  } else {
    console.log("Can quit as-is")
    return;
  }
}

doSomething("FireAndForget");
// Logs "Can quit as-is"

doSomething("ReturnPromise")
  .then(() => console.log("done"))
// Logs "Will return a Promise" followed by "done"

But suppose I want to have a function that generates such a function. For example:

function generateDoer(logger: (message: string) => void):
  ((mode: "ReturnPromise") => Promise<any>)
  | ((mode: "FireAndForget") => void) {
  return (mode: "ReturnPromise" | "FireAndForget") => {
    if (mode == "ReturnPromise") {
      logger("Will return a promise")
      return Promise.resolve();
    } else {
      logger("Can quit as-is")
      return;
    }
  }
}

Conceptually, and at runtime, the function works the same as before:

const doer = generateDoer((message) => console.log(message));

doer("FireAndForget");
// Logs "Can quit as-is"

doer("ReturnPromise")
  .then(() => console.log("done"))
// Logs "Will return a Promise" followed by "done"

BUT, from the TypeScript compiler perspective, the calls above are deeply unhappy. TypeScript tells me that I can't even pass FireAndForget as the first argument:

Argument of type 'string' is not assignable to parameter of type 'never'

And likewise, it doesn't think that I can call .then on the Promise-returning one: Property 'then' does not exist on type 'void | Promise'

Any ideas on how to convince it to work after all? Thank you very much!

TypeScript Playground link here.


P.S.: Updated from a day later: jsejcksn's solution worked for me perfectly. For posterity & ease of reference: here is a diff showing the code changes required between my original problem, and the described solution:

Diff


Solution

  • Inside the higher-order function body: you can declare the overloaded closure using idiomatic overload and implementation signatures:

    function generateDoer(logger: (message: string) => void) {
      // overload signatures:
      function doer(mode: "ReturnPromise"): Promise<void>;
      function doer(mode: "FireAndForget"): void;
      // implementation signature:
      function doer(mode: "ReturnPromise" | "FireAndForget") {
        if (mode == "ReturnPromise") {
          logger("Will return a promise");
          return Promise.resolve();
        } else {
          logger("Can quit as-is");
          return;
        }
      }
      // return the closure:
      return doer;
    }
    

    …and to describe these overload signatures in an explicit return type annotation, you can simply copy them as call signatures inside braces:

    type Doer = {
      (mode: "ReturnPromise"): Promise<void>;
      (mode: "FireAndForget"): void;
    };
    
    function generateDoer(logger: (message: string) => void): Doer {
      // …implementation from above
    }
    

    You might not encounter this syntax often, but it might help to think of the call signatures as resembling methods without property names — they're intrinsic to the type itself.


    It's not necessary to define the return type annotation as a named alias — you can write it inline. All together that looks like this:

    TS Playground

    function generateDoer(logger: (message: string) => void): {
      (mode: "ReturnPromise"): Promise<void>;
      (mode: "FireAndForget"): void;
    } {
      function doer(mode: "ReturnPromise"): Promise<void>;
      function doer(mode: "FireAndForget"): void;
      function doer(mode: "ReturnPromise" | "FireAndForget") {
        if (mode == "ReturnPromise") {
          logger("Will return a promise");
          return Promise.resolve();
        } else {
          logger("Can quit as-is");
          return;
        }
      }
      return doer;
    }