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;
}
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];
}
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,
};
}
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;
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,
};
}
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.
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,
};
}
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`
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
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>;
}
Generics vs function overloading
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!
Latest comments (3)
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.hello I'm a front-end engineer.
I really like your article and would like to translate it, is it possible?
Hey, thanks for the kind words and absolutely! Just make sure to add the link to the original source 😉