Function Overloading in Typescript

A Gentle Introduction

Often, one would start with a definition of function overloading, but let's start with a problem definition...

Consider calculating the area of a rectangle area = width * length. For a circle, the formula is area = πr^2. Now, write a function (area()) that will calculate the area of either a rectangle or a circle.

typescript
1function areaOfRectangle(width: number, length: number) {
2    return width * length;
3}
4function areaOfCircle(radius: number) {
5    return Math.PI * (radius * radius);
6}

Nice try... but we want both functions to be called area().

Enter Function Overloading

Now the definition...

"Function overloading (or method overloading) is the ability to create multiple functions of the same name with different implementations. Calls to an overloaded function will run a specific implementation of that function appropriate to the context of the call, allowing one function call to perform different tasks depending on context." Wikipedia

In other words, we can create multiple functions with the same name but with different parameters (and even different return types).

To create a single function (`area()`) to handle both a rectangle and a circle, we start by defining the "signature" each individual one.

typescript
1function area(width: number, length: number): number; //rectangle
2function area(radius: number): number; //circle

Notice that these are just signatures; they do not include any implementation information. These overload signatures are what will appear in your intellisense.

console.log( area(3) );
>> function area(radius: number): number (+1 overload)

console.log( area(5,4) );

>> function area(width: number, length: number): number (+1 overload)

Once we have written the overload signatures, we need to create a general signature that will handle all of the possible parameters and provide the implementation.

typescript
1function area(a: number, b?: number) { 
2    if (typeof(b) === 'number') {
3        //we have a rectangle...
4        return a * b;
5    }
6    //else, we have a circle...
7    return Math.PI * (a*a);
8}

Why is the second parameter optional?
When we are calculating the area of a rectangle, we need two parameters, but when we are working with a circle, we only need one. Thus, sometimes area() will have two arguments, and sometimes only one.

View in Playground

Review

There are two rules in function overloading:

  • The same name is used each of the overloaded function
  • The functions must have different type signatures, i.e. differ in the number or the types of their parameters and optionally in their return type.

A More Detailed Example

Let's consider a more involved example. We will start with a story...

The (Long) Setup

You have been tasked with creating a function that will format an age as a string for display. The function should take two arguments: years and months - both numbers.

typescript
1function displayAge(years: number, months: number) {
2    //convert to integers
3    years = Math.floor(years);
4    months = Math.floor(months);
5    //format
6    return `${years} years, ${months} months`;
7}

The team-lead loves your work, but DMs you the next day informing you that one API calculates age as a single real (float) number representing the years. She asks that you create a function for formatting age from a single numeric parameter. "No problem" is your response.

typescript
1function displayAgeFromNumber(age: number) {
2    const years = Math.floor(age);
3    const months = (age - years) * 12;
4    return displayAge(years, months);
5}

Again, your boss is amazed at your ability and sends you a coffee. The next day, she (sheepishly) lets you know that your fancy functions will be used in another project, but age is represented as a tuple or as properties in an object. "I am on it!" you reply.

typescript
1type AgeObject = {
2    years: number;
3    months: number;
4}
5function displayAgeFromObject(age: AgeObject) {
6    const {years, months} = age;
7    return displayAge(years, months);
8}
9
10type AgeTuple = [years: number, month: number];
11function displayAgeFromTuple(age: AgeTuple) {
12    const [years, months] = age;
13    return displayAge(years, months);
14}

While it takes your lead a bit more time to get back to you (she is not a TS pro like you), her enthusiasm for your work continues, and she sends you several entertaining animated GIFs. That evening while enjoying your kale and farro salad, you reflect on your day. Being an ever-dedicated, super-smart, star developer, you realize that it would make sense for there to be a function that formats age directly from a date. "I got this", you say to your pet cat, "Mr. Pickles".

typescript
1function displayAgeFromDOB(dob: Date) {
2    const ms = Date.now() - dob.getTime();
3    const age = ms / (
4        1000 //: 1000 ms/sec
5        * 60 //: 60 sec/min
6        * 60 //: 60 min/hr
7        * 24 //: 24 hr/day 
8        * 365.25  //: 365-1/4 days/yr 
9    );
10    return displayAgeFromNumber(age);
11}

The next day, you DM your lead to inform her of your new function to handle formatting age from a date. She is flabbergasted: "I was just going to ask you to do that!". She sends you bag of M&Ms (no peanuts - of course) and ask you to join her at the annual Typescript convention.

You are so excited and even remember to pack an extra bag for all of the swag you will get on the convention floor. The first session you and your lead attend is titled "Function Overloading in Typescript". By the end, you know exactly what your boss is going to ask of you!

Overloading Signature

When you get back to your desk, you pull out your notes from the Function Overloading session:

typescript
1//Start by defining the signatures of each overload
2function add(a: number, b: number): number;
3function add(a: string, b: string): string;
4function add(a: boolean, b: boolean): boolean;
5//General signature to handle all options
6function add(a: unknown, b: unknown) {
7    if (typeof(a) === 'number' && typeof(b) === 'number') {
8        return a + b;
9    }
10    else if (typeof(a) === 'string' && typeof(b) === 'string') {
11        return a + ' ' + b;
12    }
13    else if (typeof(a) === 'boolean' && typeof(b) === 'boolean') {
14        return a && b;
15    }
16
17    //else
18    return undefined;   //or throw error?
19}
20
21const test1 = add(4, 5);
22//      ^? const test1: number
23const test2 = add("hello", "world");
24//      ^? const test2: string
25const test3 = add(true, false);
26//      ^? const test3: boolean

View in Playground

In your notes, you have underlined and highlighted: "Start by defining the signatures of each overload..."

typescript
1function displayAge(years: number, months: number): string;
2function displayAge(age: number): string;
3function displayAge(age: AgeObject): string;
4function displayAge(age: AgeTuple): string;
5function displayAge(dob: Date): string;

The five possible parameter options are listed. "Be sure to include the return type for each of the functions!" you have written in the margins of your notes.

Implementation Signature

Now add the general signature (to handle all possible parameters) and implement the function!

typescript
1function displayAge(years: number, months: number): string;
2function displayAge(age: number): string;
3function displayAge(age: AgeObject): string;
4function displayAge(age: AgeTuple): string;
5function displayAge(dob: Date): string;
6function displayAge(a: unknown, b?: unknown) {
7    if (typeof(a) === 'number' && typeof(b) === 'number') {
8        //convert to integers
9        a = Math.floor(a);
10        b = Math.floor(b);
11        //format
12        return `${a} years, ${b} months`;
13    }
14    else if (typeof(a) === 'number') {
15        const years = Math.floor(a);
16        const months = (a - years) * 12;
17        return displayAge(years, months);
18    }
19    else if (a instanceof Date) {
20        const ms = Date.now() - a.getTime();
21        const age = ms / (
22            1000 //: 1000 ms/sec
23            * 60 //: 60 sec/min
24            * 60 //: 60 min/hr
25            * 24 //: 24 hr/day 
26            * 365.25  //: 365-1/4 days/yr 
27        );
28        return displayAge(age);
29    }
30    else if ('years' in (a as AgeObject) && 'months' in (a as AgeObject)) {
31        const { years, months } = (a as AgeObject);
32        return displayAge(years, months);
33    }
34    else if (Array.isArray(a) && a.length === 2) {
35        const [ years, months ] = a;
36        return displayAge(years, months);
37    }
38
39    //else - not handled
40    return "";
41}

View in Playground

You have done it! You commit your code and wait for you lead to heap the (well deserved) praises on you. Instead, you get an email from HR letting you know that your team-lead has left to pursue her love of Urban Farming, and they have replaced the team-lead position with someone from Microsoft named Clippy.

What About Classes?

So far, we have focused on overloading functions; but we can overload methods in classes the same way.

Method Overloading

Consider a calendar for students to track their lives. Students would want to be able to add homework assignments, be reminded of exam dates, and (of course) track parties.

typescript
1type CalendarEvent = {
2    kind: 'Assignment' | 'Exam' | 'Party',
3    date: Date,
4    text: string;
5    data?: string;
6}
7class StudentCalendar {
8    private readonly events: CalendarEvent[] = [];
9
10    add(event: CalendarEvent) { /* ... */ }
11}

Remembering students can be lazy, you decide to include the ability to pass in an Assignment, Exam, or Party into our `add()` method. Easy!

typescript
1class StudentCalendar {
2    private readonly events: CalendarEvent[] = [];
3
4    add(event: CalendarEvent | Assignment | Exam | Party) { 
5      /* ... */ 
6    }
7}

This will work, but you are a TS Pro and have studied function overloading. So you decide to implement add() using method overloading.

typescript
1class StudentCalendar {
2    private readonly events: CalendarEvent[] = [];
3
4    add(event: CalendarEvent): void;
5    add(assignment: Assignment): void;
6    add(exam: Exam): void;
7    add(party: Party): void;
8    add(data: CalendarEvent | Assignment | Exam | Party) {
9        if ('text' in data) {
10            //data is a CalendarEvent
11            this.events.push(data);
12        }
13        else {
14            //data is Assignment | Exam | Party
15            //so we need to create the CalendarEvent
16            const event: CalendarEvent = {
17                kind: data.kind,
18                date: data.kind === 'Assignment' ? data.due : data.date,
19                text: data.kind === 'Assignment' 
20                        ? data.description
21                        : data.kind === 'Exam'
22                            ? data.topic
23                            : data.location,
24                data: 'course' in data ? data.course : undefined,
25            };
26            this.events.push(event);            
27        }        
28    }
29}

Well done. Notice that the details of the implementation are the same with or without method overloading, but by adding the method signatures, you will now get nice intellisense.

View the full code (including type definitions) in this Playground

Constructor Overloading

Continuing with our StudentCalendar, let's make it so you can optionally instantiate the class with a single event or set of events. And, yes, you are a TS Pro so constructor overloading is your choice of implementation.

typescript
1class StudentCalendar {
2    private readonly events: CalendarEvent[];
3
4    constructor();
5    constructor(event: CalendarEvent);
6    constructor(events: CalendarEvent[]);
7    constructor(data?: CalendarEvent | CalendarEvent[]) {
8        if (data) {
9            this.events = Array.isArray(data)
10                ? data.map(evt => { return {...evt}; })
11                : [{...data}]
12        }
13        else {
14            this.events = [];
15        }
16    }
17}

View in Playground

Why Overload?

We could have gotten away with not overloading the functions/methods/constructors presented in this article. In fact, overloading is just providing adding the overload signatures. Without them, the function (or class constructor/method) work just fine.

So why should we go through the extra work of adding overload signatures?

First - you will get better intellisense when you use function overloading. Have a look at the intellisense of displayAge() with and without overloading.

typescript
1// without overloading
2function displayAge(a: unknown, b?: unknown): string
3
4// with overloading (scrolling through each)
5function displayAge(years: number, months: number): string (+4 overloads)
6function displayAge(age: number): string (+4 overloads)
7function displayAge(age: AgeObject): string (+4 overloads)
8function displayAge(age: AgeTuple): string (+4 overloads)
9function displayAge(dob: Date): string (+4 overloads)

Clearly, overloading gives you more detail on the argument(s) for the function.

Second - overloading better communicates your intent - what you are trying to do with this set of code. The rewards better communication of intent are twofold. As you write your code, overloading allows you to articulate (in your mind and in code) exactly what you are trying to accomplish: i.e. how this function will be used. Additionally, better communication of intent will help you and others maintain the code in the future; the more we know about the original intent of a piece of code the easier it is to keep the code up-to-date.

Resources

Guides

Video

popularity
On Fire
posted
Jun. 04, 2024