DEV Community

Cover image for Generics vs Function Overloading vs Union Type Arguments in TypeScript
Pedro Figueiredo
Pedro Figueiredo

Posted on • Updated on

Generics vs Function Overloading vs Union Type Arguments in TypeScript

Decision diagram on typescript's functions

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

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;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's 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;

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];
}
Enter fullscreen mode Exit fullscreen mode

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

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's 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)

// Overload signatures
function getPerson(name: string): SingleNamePerson;
function getPerson(name: string, surname: string): FullNamePerson;
Enter fullscreen mode Exit fullscreen mode

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.

// 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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

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;

// 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

Generic Functions

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`
Enter fullscreen mode Exit fullscreen mode

TypeScript's 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).

// 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
Enter fullscreen mode Exit fullscreen mode

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;

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>;
}
Enter fullscreen mode Exit fullscreen mode

Generics vs function overloading

generics vs functions overloading example

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 if you want to read about TypeScript best practices or just web development in general!

Top comments (3)

Collapse
 
leandro_n_ortiz profile image
Leandro Ortiz

Nice, but in the case that a Union was added, the function arg name was petOrOwner. I would prefer to use Overloading to be able to give a specific arg name for each case. It is easier for the person that will use it.

Collapse
 
kongwoojeong profile image
Zayden

hello I'm a front-end engineer.
I really like your article and would like to translate it, is it possible?

Collapse
 
pffigueiredo profile image
Pedro Figueiredo

Hey, thanks for the kind words and absolutely! Just make sure to add the link to the original source 😉