4

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?

1 Answer 1

1

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)

Sign up to request clarification or add additional context in comments.

4 Comments

This was super helpful. I think the biggest thing that I didn't understand was putting multiple method definitions like that. Does the order of those method definitions matter? And, can you please elaborate on required for closure compatibility? I don't understand the purpose of those 2 lines.
The order does not matter. The closure comment is due to how closures work in JavaScript. see: developer.mozilla.org/en-US/docs/Web/JavaScript/Closures Need to make sure the variable doesn't change when the callback scope is executed.
Which section is relevant to this specific thing on that link?
I believe Creating closures in loops: A common mistake, not a 100% sure about the implications, but the TypeScript engine warns about it otherwise. You can try without it and see what the error is.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.