Generics vs Function Overloading vs Union Type Arguments in TypeScript
Tags: typescript, webdev
Published: 2022-04-25 14:58:06

![Decision diagram on typescript's functions](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yrpntycf9gak3azplaue.png)
 

## TypeScript Functions

When it comes to writing TypeScript functions, it is possible, that you have experienced this sort of thinking:

> _This looks kinda weird, should I try this in another way? Maybe **function overloading**, or perhaps **generics** would help here. 🤔_  

And you ended up changing for one of them, without really knowing if it was the right way.

I've been there and have certainly done this countless times — but what if there was **a system** that could help you solve this little nuance? 

That's exactly what I'm presenting here — a system that will allow you to make a **good and consistent** decision on how you write a TypeScript function.

## Union Types

```ts
type Pet = {
  name: string;
};

type PetOwner = {
  name: string;
  pet: Pet;
};

function getPetName(petOrOwner: Pet | PetOwner) {
  if ("pet" in petOrOwner) {
    return petOrOwner.pet.name;
  }

  return petOrOwner.name;
}
```

TypeScript's [union types](https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-types) are super useful when it comes to typing anything that can have multiple type definitions.

For functions in particular, it is super useful to type its **parameters or return types**. And you are perhaps using it more than you knew, because every time you define a argument as `optional` , it actually becomes `type | undefined`, meaning it is a union type underneath!

### When to use

When it comes to functions, this is pretty much the most basic strategy for typing its parameters. So my advice is to stick to it whenever possible, but it has more restraints than the others strategies and it doesn't work for more complex scenarios.

Use **union types** when:

1- You are **aware of all the possible members** of each union type at the moment of the function's declaration;
2- The **return type doesn't change** depending on the argument's types;

```ts
type Pet = {
  name: string;
};

type PetOwner = {
  ownerName: string;
  pet: Pet;
};

// Don't do this - you will return an ambiguous type ❌
function getObject(petOrOwner: Pet | PetOwner): Pet | PetOwner {
  return petOrOwner;
}


// You have no idea which will be the array type ❌
function getFirstElementArray(arr: (string | number)[]): any  {
  return arr[0];
}
``` 

Also, when using union types for typing functions, you should most likely **not need to type the return type explicitly**. That's just because the return type shouldn't change at all for this way of doing things.


## Function Overloading

```ts
type SingleNamePerson = {
  name: string;
};

type FullNamePerson = {
  name: string;
  surname: string;
};

// Overload signatures
function getPerson(name: string): SingleNamePerson;
function getPerson(name: string, surname: string): FullNamePerson;

// Implementation signature
function getPerson(
  name: string,
  surname?: string
): SingleNamePerson | FullNamePerson {
  if (name && surname) {
    return {
      name,
      surname,
    };
  }

  return {
    name,
  };
}
``` 
TypeScript's [function overloads](https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads) is a great way to specify multiple function signatures in a super declarative way, while making each of those have its own type definition, either for the arguments or the return type.

To leverage this way of typing a function all you need to do is the following:

1- **Define your functions' possible signatures** (the different overloads)
```ts
// Overload signatures
function getPerson(name: string): SingleNamePerson;
function getPerson(name: string, surname: string): FullNamePerson;
```

2- **Define your functions implementations** - this function's signature must be a union over the other previously created functions, so that it respects every single overload.

```ts
// name - required because it is defined in both overloads
// surname - optional, because it is only present in one of the overloads

// return type - SingleNamePerson | FullNamePerson because it can be either one of those

function getPerson(
  name: string,
  surname?: string
): SingleNamePerson | FullNamePerson {
  if (name && surname) {
    return {
      name,
      surname,
    };
  }

  return {
    name,
  };
}
``` 

### When to use

Function overloading works pretty well with more difficult and dynamic signatures, as it will provide a way to define multiple combinations in a way that is easy to understand.

![function overloads usage example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mr1ii3xf7j1lxbqmirwg.gif)
 
Use **function overloading** when:

1- You are **aware of all the possible members** of each union type at the moment of the function's declaration;
2- The **return type changes** depending on the argument's types;
3- The **return type isn't a direct mapping** of the provided parameters;

```ts
// Don't do this ❌ - the return type is a direct mapping of the provided argument (use a generic instead)
function getPerson(person: SingleNamePerson): SingleNamePerson;
function getPerson(person: FullNamePerson): FullNamePerson;
function getPerson(person: {
  name: string;
  surname?: string;
}): SingleNamePerson | FullNamePerson {
  const { name, surname } = person;

  if (name && surname) {
    return {
      name,
      surname,
    };
  }

  return {
    name,
  };
}
```
## Generic Functions

```ts
type SingleNamePerson = {
  name: string;
};

type FullNamePerson = {
  name: string;
  surname: string;
};

const singleNamePerson: SingleNamePerson = {
  name: "Bob",
};

const fullNamePerson: FullNamePerson = {
  name: "Bob",
  surname: "Smith",
};

function getPerson<PersonT>(arg: PersonT): PersonT {
  return arg;
}

getPerson(singleNamePerson); // Return type => `SingleNamePerson`
getPerson(fullNamePerson); // Return type => `FullNamePerson`
``` 
 
TypeScript's [Generic Functions](https://www.typescriptlang.org/docs/handbook/2/functions.html#generic-functions) is probably **the most versatile way** to create a function that is dynamic in some way or another.

It allows you to get **full type support** on every possible abstraction for a function that deals with very different scenarios (and where you are not fully aware of what may be passed in as an argument ahead of time).

But all these opportunities and versatility come with a cost.  As generics seem to fit everywhere, **developers tend to overuse them**, and believe me, no one wants to edit a function surrounded by overly complex generic types, especially if it was written by another engineer.

### When to use

Generic Functions are the only solution you have to solve the  common problem between union types and function overloading — the capacity of adding type support when we are **not aware of all the possible types** beforehand.

Not only that, but Generics are also a great option when the return type of a function is associated with the types of the parameters (even if you are aware of them ahead of time).

```ts
// Defining the return type based on the argument's type
function getFirstElement<T>(array: T[]): T {
    return array[0];
}

const a = getFirstElement([1, 2, 3]); // return type => number
const b = getFirstElement(["Hello", "World"]); // return type => string
const c = getFirstElement([true, false]); // return type => boolean
``` 

Use **generic functions** when:
1- You are **NOT aware of the argument types** beforehand;
2- The **return type is a direct mapping** (or close to it) of the provided parameters;

```ts
type Dog = {
  name: string;
  toy: string;
};

type Cat = {
  name: string;
  furrballs: number;
};

type Turtle = {
  name: string;
  isMainlyAquatic: boolean;
};

// This can get tricky pretty quickly ❌
type GetAnimalReturnType<AnimalT> = AnimalT extends Dog
  ? "canine"
  : AnimalT extends Cat
  ? "feline"
  : "turtle";

// We need to use type assertions - not ideal ❓
function getAnimalType<AnimalT>(animal: AnimalT): GetAnimalReturnType<AnimalT> {
  if ("toy" in animal) {
    return "canine" as GetAnimalReturnType<AnimalT>;
  }

  if ("furrballs" in animal) {
    return "feline" as GetAnimalReturnType<AnimalT>;
  }

  return "turtle" as GetAnimalReturnType<AnimalT>;
}
```

## Generics vs function overloading

![generics vs functions overloading example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zzjbc3tifql3azf9xxkr.png)
 
When it comes to choosing between **function overloading** and **generics** the line is blurrier and the decision isn't quite clear. As you can observe in the image above, these two feature usage intersects when it comes to typing a function that has known argument types and whose return type depends on those very same arguments.

### Which to choose

The answer will depend on the developer's preference, but my opinion is that to keep things simple you should opt for `generics` instead of `function overloading` only when this rule applies:

> The code that you need to write to define the return type **doesn't have more than 1 level of depth of `extends` expressions**. 

That's the rule that I use for myself to keep code consistent and easier for others to read.

## Conclusion

There are multiple techniques to write dynamic functions in TypeScript and by applying this "mental model", you will be able to define those functions in a more consistent and clean way, while also making usage of each technique for what it was actually intended. 👇

- **Unions** => Use when return type doesn't change;
- **Function Overloading** => Use when you are aware of the argument types and the return type **does change** depending on the argument types;
- **Generics** => Use when you are not aware of the argument types or the return type is a direct mapping of the argument types.
   
---

_Make sure to follow me on [twitter](https://twitter.com/P_FFigueiredo) if you want to read about TypeScript best practices or just web development in general!_