Quick guide to Function Overloading in TypeScript

Quick guide to Function Overloading in TypeScript

Or how to properly give types to your function

We use functions all the time in our apps, generally speaking most of the JS code are functions. TypeScript gives us many ways to describe them. For a basic function, this is fairly easy to do, but what if the function takes a variety of argument counts and types, or even returns a different type depending on how it's called? For such cases, TypeScript has the handy function overloading feature. Let's see how to use it and how it can help you improve your code.

Function typing

I feel hungry now, so let's create a function that will cook a dish:

function cookDish(ingredient: string): string {
  return `${ingredient}🍴 is ready, bon appétit!`;
}

We passed a single string argument as an ingredient and it returns a string dish after invocation:

cookDish('🥚'); // '🍳🍴 is ready, bon appétit!'

But wait, this is not enough, I definitely need a sandwich here! To cook it, we need to modify our function, so that it could accept multiple ingredients:

function cookDish(ingredient: string | string[]): string {
  const tableSet = '🍴 is ready, bon appétit!';
  if (typeof ingredient === 'string') {
    return `${ingredient}${tableSet}`;
  } else if (Array.isArray(ingredient)) {
    return `${ingredient.join('')}${tableSet}`
  }
  throw new Error('Nothing to cook 😭');
}

We used Union type to describe our function argument, that can be either one string or multiple string[] of them:

cookDish('🌽'); // '🍿🍴 is ready, bon appétit!';
cookDish(['🍞', '🍅', '🥓']); // '🥪🍴 is ready, bon appétit!';

Adding types to support various arguments is a common and good approach in most cases, but sometimes you need to explicitly define all the ways to call a function. This is where function overloading comes into play.

Function overloading

In the case of a rather complex function, to improve usability and readability, it is always better to use the function overloading feature to improve usability and readability. This approach is considered the most flexible and transparent.

To use this, we need to write some function signatures:

  1. Overload signature - defines different ways to call a function: arguments and return types, and doesn't have a body. There can be multiple overload signatures (usually two or more).
  2. Implementation signature - provides an implementation for a function: function body. There can be only one implementation signature and it must be compatible to overload signatures.

Let's rewrite our cookDish using function overloading:

// Overload signature
function cookDish(ingredient: string): string
function cookDish(ingredients: string[]): string

// Implementation signature
function cookDish(ingredient: any): string {
  const tableSet = '🍴 is ready, bon appétit!';
  if (typeof ingredient === 'string') {
    return `${ingredient}${tableSet}`;
  } else if (Array.isArray(ingredient)) {
    return `${ingredient.join('')}${tableSet}`
  }
  throw new Error('Nothing to cook 😭');
}

Two overload signatures describe two different ways the function can be called: with string or string[] argument. In both ways we will get a cooked string dish as a result. An implementation signature defines the behavior of a function within a function body. Our function invocation doesn't change though, we can cook as before:

cookDish('🍚'); // '🍙🍴 is ready, bon appétit!';
cookDish(['🫓', '🍅', '🥑']); // '🥙🍴 is ready, bon appétit!';

const money: any = 100;
cookDish(money);
/**
⛔ No overload matches this call.
Overload 1 of 2, '(ingredient: string): string', gave the following error.
  Argument of type 'any' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(ingredients: string[]): string', gave the following error.
  Argument of type 'any' is not assignable to parameter of type 'string[]'.
*/

Note at the last example above: (money can't cook you a dish) although an implementation signature is the one that implements our function, it cannot be called directly, even though it accepts any as an argument function cookDish(ingredient: any). You can call only overload signatures.

It's dinner time! let's look at a slightly more complex example:

function cookDinner(dish: string): string
function cookDinner(money: number): string // Wrong argument type
/**
⛔ This overload signature is not compatible with its implementation signature.
*/
function cookDinner(dish: string, drink: string, dessert: string): string
function cookDinner(dish: string, drink?: string, dessert?: string): string {
  let dinner = `${dish}🍴`;
  if (drink && dessert) {
    dinner += ` ${drink}🧊${dessert}🥄`;
  }
  return `${dinner} is ready, bon appétit!`
}

cookDinner('🍝'); // '🍝🍴 is ready, bon appétit!';
cookDinner('🍔', '🥤', '🍰'); // '🍔🍴🥤🧊🍰🥄 is ready, bon appétit!';

cookDinner('🍺', '🍸') // 🤒 Invalid number of arguments
/**
⛔ No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
*/

Need to remember that implementation signature must be compatible to overloads and can't be called directly.

Conclusion

Function overloading is a powerful TypeScript feature that allows you to type your functions more elegantly.

More read:

Hope you enjoyed this guide, stay tuned for more.