Charlie Fish
Charlie Fish

Reputation: 20536

How to overload function in TypeScript with optional parameters?

I have the following code that is partly converted to TypeScript (from JavaScript).

Basically if a callback parameter exists, I always want the function itself to return void. Otherwise, return type Promise<object>. With an optional parameter (settings) before that (so technically the callback parameter could be passed in as the settings parameter, which the first few lines of the function handle that use case).

For backwards compatibility purposes (and to keep code DRY), I do not want to create another function called savePromise or saveCallback and separate it out. I'm trying to figure out how to get TypeScript to be smart enough to understand this logic somehow.

type CallbackType<T, E> = (response: T | null, error?: E) => void;

class User {
    save(data: string, settings?: object, callback?: CallbackType<object, string>): Promise<object> | void {
        if (typeof settings === "function") {
            callback = settings;
            settings = undefined;
        }

        if (callback) {
            setTimeout(() => {
                callback({"id": 1, "settings": settings});
            }, 1000);
        } else {
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve({"id": 1, "settings": settings});
                }, 1000);
            });
        }
    }
}

const a = new User().save("Hello World"); // Should be type Promise<object>, should eventually resolve to {"id": 1, "settings": undefined}
const b = new User().save("Hello World", (obj) => {
    console.log(obj); // {"id": 1, "settings": undefined}
}); // Should be type void
const c = new User().save("Hello World", {"log": true}); // Should be type Promise<object>, should eventually resolve to {"id": 1, "settings": {"log": true}}
const d = new User().save("Hello World", {"log": true}, (obj) => {
    console.log(obj); // {"id": 1, "settings": {"log": true}}
}); // Should be type void

I'm pretty sure the type file I'm aiming for would be something along the lines of the following. Not sure I'm accurate here tho.

save(data: string, settings?: object): Promise<object>;
save(data: string, callback: CallbackType<object, string>): void;
save(data: string, settings: object, callback: CallbackType<object, string>): void;

It seems like the callback parameter being passed in as the settings parameter use case can be handled by doing something like:

save(data: string, settings?: object | CallbackType<object, string>, callback?: CallbackType<object, string>): Promise<object> | void

But that is super messy, and from my experience it doesn't seem like TypeScript is smart enough to realize that settings will always be an optional object after those first 4 lines of code in the function. Which means when calling callback you have to type cast it, which again, feels really messy.

How can I achieve this with TypeScript?

Upvotes: 4

Views: 972

Answers (1)

DoronG
DoronG

Reputation: 2663

TL;DR

Here's the solution (with some refactoring):

type CallbackType<T, E> = (response: T | null, error?: E) => void;
interface ISettings {
  log?: boolean;
}
interface ISaveResult {
  id: number;
  settings: ISettings | undefined;
}

class User {
  save(data: string): Promise<ISaveResult>;
  save(data: string, settings: ISettings): Promise<ISaveResult>;
  save(data: string, callback: CallbackType<ISaveResult, string>): void;
  save(data: string, settings: ISettings, callback: CallbackType<ISaveResult, string>): void;
  save(data: string, settings?: ISettings | CallbackType<ISaveResult, string>, callback?: CallbackType<ISaveResult, string>): Promise<ISaveResult> | void {
    if (typeof settings !== "object" && typeof settings !== "undefined") {
      callback = settings;
      settings = undefined;
    }

    const localSettings = settings; // required for closure compatibility
    if (callback) {
      const localCallback = callback; // required for closure compatibility
      setTimeout(() => {
        localCallback({ id: 1, "settings": localSettings });
      }, 1000);
    } else {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({ id: 1, "settings": localSettings });
        }, 1000);
      });
    }
  }
}

const a = new User().save("Hello World"); // User.save(data: string): Promise<ISaveResult>

const b = new User().save("Hello World", obj => { 
  console.log(obj); // obj: ISaveResult | null
}); // User.save(data: string, callback: CallbackType<ISaveResult, string>): void

const c = new User().save("Hello World", { "log": true }); // User.save(data: string, settings: ISettings): Promise<ISaveResult>

const d = new User().save("Hello World", { "log": true }, (obj) => {
  console.log(obj); // obj: ISaveResult | null
}); // User.save(data: string, settings: ISettings, callback: CallbackType<ISaveResult, string>): void

see code pan

Explanation

a Function is an Object as well, so TypeScript was not able to implicitly distinguish between the two. By creating a specific ISettings interface, you allow TypeScript to distinguish between a settings object and a callback function.

The easiest way to see that is look at the errors TypeScript puts out and the types of the variables as the code flow progresses, e.g. (your code):

  • When hovering over settings in if condition:

    enter image description here

  • When hovering over settings inside if block:

    enter image description here

  • callback assignment error:

    Type 'Function' is not assignable to type 'CallbackType'. Type 'Function' provides no match for the signature '(response: object | null, error?: string | undefined): void'.(2322)

Upvotes: 1

Related Questions