Understanding Function Overloading in TypeScript

Written by olgakiba | Published 2022/10/10
Tech Story Tags: typescript | generics | unions | software-development | software-engineering | javascript | javascript-development | typescript-tutorial

TLDRIn TypeScript, we can specify a function that can be called in different ways by writing function overloading signatures. To get **overload signatures**, we should write two function signatures: one accepting a string type, and another accepting argument with an array of numbers type. Overload 1 of 2, '(s: string): number', gave the following error error: 'Hello' is not assignable to type 'number' Type 'number[]' is 'not assignable' to type'string.via the TL;DR App

*Function Type overloading (next—function overloading, overloads). TypeScript is a compile-time type checking. At runtime, there is regular JavaScript. Therefore, there is function type overloading, but there is no function overloading.

I’ve used generics and union types in TypeScript before but I’ve never heard about function overloading. So this article is about taking a closer look at function overloading and comparing it with generics and union types.

Function overloading and union types

Function overloading – some of JavaScript functions can be called in a variety of argument counts and types. In TypeScript, we can specify a function that can be called in different ways by writing overload signatures.

Let’s have a look at the example from the documentation:

function getLength(str: string): number;
function getLength(arr: number[]): number;
function getLength(x: any) {  
  return x.length;
}

Here we have a function to return a length of a str (one argument) or an arr (one argument). To get overload signatures, we should write two function signatures: one accepting an argument with a string type, and another accepting argument with an array of numbers type.

Then, we have to write something called a compatible signature - a function implementation with a compatible signature. The previous two functions already have implementation signatures, but that signature can’t be called directly.

You can say: “But this can be written in a much easier way by using union types” and you would be totally right:

function getLength(x: number[] | string): number {
  return x.length;
}

Even documentation says so:

Always prefer parameters with union types instead of overloads when possible.

And there is a good reason for this. Let’s get back to the example with overload signatures and try to call that function next way:

getLength(Math.random() > 0.5 ? "hello" : [0]);
No overload matches this call.
  Overload 1 of 2, '(s: string): number', gave the following error.
    Argument of type 'number[] | "hello"' is not assignable to parameter of type 'string'.
      Type 'number[]' is not assignable to type 'string'.
  Overload 2 of 2, '(arr: number[]): number', gave the following error.
    Argument of type 'number[] | "hello"' is not assignable to parameter of type 'number[]'.
      Type 'string' is not assignable to type 'number[]'.

If you decided to use function overloading you have to be very careful with all the ways of calling that function.

In this case, because both overloads have the same argument count and same return type it is a good practice to use union types instead of function overloading to keep code clean and simple.

Summing up here are the conditions when using union types is better than using function overloading:

  1. You know all the argument types at the moment of the function declaration;
  2. The return type doesn't change depending on the argument's types. You can even leave it out of the signature since it is assumed that it will be immutable;

Our example satisfied all the conditions:

  • we defined that argument as a string or an array of numbers at the moment of the function's declaration;

  • we know that return type always would be a number since it is a value of length property;

So what do we need this function overloading for?

Please, take a look at this example. It’s a bit more complicated but it would help you to understand when using overloads is more profitable than union types:

type FullOrderInfo = {
  orderStatus: string;
  deliveryDate: string;
  orderNumber: string
}

type PartialOrderInfo = {
  orderNumber: string;
}

function getOrderInfo(email: string): PartialOrderInfo;
function getOrderInfo(fullname: string, trackingNumber: string;, phoneNumber: string): FullOrderInfo;
function getOrderInfo(fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo {
  if (trackingNumber !== undefined && phoneNumber !== undefined) {
  // there might be a request to backend which accepts strictly 3 args: fullname, trackingNumber and phoneNumber. Otherwise an error would be throught.
    return  {
      orderStatus: 'inProgress';
      deliveryDate: '20/10/2022';
      orderNumber: '372956743728'
    };
  } else {
    return {
      orderNumber: '372956743728';
    };
  }
}

Here we have a function to return some orderInfo that takes either an email (one argument) or a fullname, a trackingNumber, a phoneNumber (three arguments). So this function works only with 1 or 3 arguments (2 arguments are not acceptable). We’ve made two overload signatures and the final one is compatible signatures. Now let’s see what we get after calling this function with different counts of arguments:

const info1 = getOrderInfo('olgakiba@gmail.com'); // everithing is fine (one arg satisfies the condition)
const info2 = getOrderInfo('olgakiba', 'dh546kwL, '796677334'); // still fine (three args satisfy the condition)
const info3 = getOrderInfo('olgakiba', '796677334'); // No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.

That’s the exact result we wanted. Our function expects only 1 or 3 arguments as it is defined in overload signatures. Also as you can see overload signatures return different types, so it would not be correct to use union types here.

Note, if your function is supposed to return different types you need to use not a union type sign (“|”), but an intersection type sign (“&“) in the implementation signature:

function getOrderInfo(fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo {
  // function body
}

Summing up it is a good practice to use union types or function overloading when you know all the argument types at the moment of the function declaration. Use union types when the return type doesn't change and function overloading when the return type does change depending on the argument's types.

We saw the example of using function overloading with functions created by using function declaration but you can also apply it with functions created by using function expression, arrow functions, and classes methods:

  1. Function expression:

    type FullOrderInfo = {
      orderStatus: string;
      deliveryDate: string;
      orderNumber: string
    }
    
    type PartialOrderInfo = {
      orderNumber: string;
    }
    
    type GetOrderInfo = {
     (email: string): PartialOrderInfo;
     (fullname: string, trackingNumber: string, phoneNumber: string): FullOrderInfo;
    };
    
    const getOrderInfo:GetOrderInfo = function (fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo  {
     if (trackingNumber !== undefined && phoneNumber !== undefined) {
       return  {
          orderStatus: 'inProgress';
          deliveryDate: '20/10/2022';
          orderNumber: '372956743728'
        };
      } else {
        return {
          orderNumber: '372956743728';
        };
      }
    };
    

  2. Arrow function:

    type FullOrderInfo = {
      orderStatus: string;
      deliveryDate: string;
      orderNumber: string
    }
    
    type PartialOrderInfo = {
      orderNumber: string;
    }
    
    type GetOrderInfo = {
     (email: string): PartialOrderInfo;
     (fullname: string, trackingNumber: string, phoneNumber: string): FullOrderInfo;
    };
    
    const getOrderInfo:GetOrderInfo = (fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo => {
     if (trackingNumber !== undefined && phoneNumber !== undefined) {
       return  {
          orderStatus: 'inProgress';
          deliveryDate: '20/10/2022';
          orderNumber: '733487232'
        };
      } else {
        return {
          orderNumber: '372956743728';
        }
    };
    

  3. Class method - here is the same logic as in the examples above so I combined and wrote it in a short way:

    type FullOrderInfo = {
      orderStatus: string;
      deliveryDate: string;
      orderNumber: string
    }
    
    type PartialOrderInfo = {
      orderNumber: string;
    }
    
    type GetOrderInfo = {
     (email: string): PartialOrderInfo;
     (fullname: string, trackingNumber: string, phoneNumber: string): FullOrderInfo;
    };
    
    class OrderInfoGetter {
      // Function declaration
      getOrderInfo(email: string): PartialOrderInfo;
      getOrderInfo(fullname: string, trackingNumber: string;, phoneNumber: string): FullOrderInfo;
      getOrderInfo(fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo {
        // function body
      };
    
      // Function expression
      getOrderInfo:GetOrderInfo = function (fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo  {
        // function body
      }
    
      // Arrow function
      getOrderInfo:GetOrderInfo = (fullnameOrEmail: string, trackingNumber?: string, phoneNumber?: string): PartialOrderInfo & FullOrderInfo => {
        // function body
      }
    }
    

Generics

You might think: “Okay, now we know when to use union types and when — overloads, let’s go try it in practice”. But before you do this we have to define one problem between those solutions — having a possibility of adding type support when we are not aware of all the possible types beforehand. This is also a quite common case and as you can see it doesn’t satisfy all the conditions of using union types or function overloading.

A great solution for that is using generics:

function getFirstArrayElement<T>(array: T[]): T {
    return array[0];
}

const firstArrayElement1 = getFirstArrayElement([1, 2, 3]); // return type: number
const firstArrayElement2 = getFirstArrayElement(["first", "second", "third"]); // return type: string
const firstArrayElement3 = getFirstArrayElement([true, false, true]); // return type: boolean

A good practice of using generics is when:

  1. You don’t know the argument types at the moment of the function declaration;
  2. The return type changes depending on the argument's types. In other words, it is a direct mapping (or close to it) of the argument types;

Conclusion

Of all the methods described above, function overloading was new to me. I think that overloads won't be an integral part of your code. It can be used mainly to continue and support previous work. Imagine you come to a huge old project that dozens and hundreds of developers have worked with and for sure it might have some problems and mistakes made by previous developers.

So you have to write a function wrapper on a function that somehow has two signatures and different return types. The best way to implement this function wrapper would be by using function overloading. But you have to understand that this is a forced solution to support old code.

In other words, if you are writing a project from scratch and you already have the usage of function overloading in the code in most cases it means that your architecture might have some holes and you might need to reconsider it. Because first SOLID principle says:

Every class, module, or function in a program should have one responsibility/purpose in a program

So in this case (if it is possible) it would be better to divide your function into several ones to keep the code clean and understandable for other people. But of course, there might be cases where overloads would be the only correct solution, e.x. working with chrome.tabs.query or complicated business conditions.

Function overloading is an excellent tool. It would be good for every developer to know what it is and how it works. However, it must be used carefully and only where it’s necessary.


Written by olgakiba | Love Chinese culture and coding
Published by HackerNoon on 2022/10/10