Martin Milo

A blog about software development, architecture, and more.

Weekly updates on latest blog posts, thoughts and useful stuff.

Typescript Function Overloading

typescript function basics

Function overloading or method overloading in Typescript allows you to declare multiple functions or methods with the same name to perform specific tasks based on different parameters and return types.

Simply put, instead of implementing multiple methods with different names and parameters, you implement only one method with multiple declarations. Each declaration contains different parameters and return types.

Let's take a look at the following example:

class UserRepository {
  public async update(where: UpdateSelfWhere, data: UpdateSelfData): Promise<void>;
  public async update(where: UpdateAccountWhere, data: UpdateAccountData): Promise<void>;

  public async update(where: any, data: any): Promise<void> {
    await this.dataSource.update(User, where, data);
  }
}

type UpdateSelfWhere = { id: string };
type UpdateSelfData = { name: string };

type UpdateAccountWhere = { email: string };
type UpdateAccountData = { password: string };

As you can see, we have two method declarations for the same implementation, the difference is in the types of the parameters for each method declaration.

Let's consider you have to call the repository methods in a UserService class. This is how you do it:

class UserService {
  const where = { email: 'martin@milomedia.eu' };
  const data = { password: '123456' };
  await this.userRepository.update(where, data);
  // ✅ Works fine since we're passing correct combination of params

  const where = { id: '3198e538-48b5-4f52-9a9f-de788ffc7c1e' };
  const data = { name: 'Martin' };
  await this.userRepository.update(where, data);
  // ✅ Works fine since we're passing correct combination of params

  const where = { email: 'martin@milomedia.eu' };
  const data = { name: 'Martin' };
  await this.userRepository.update(where, data);
  // ❌ Throws type error because you're trying to pass incorrect combination of params
}

How and why does this help? Well, in our example we have two method declarations for two cases. One case allows to update of account data such as email and password by the user ID while the second allows individual users to update their personal details, such as name.

Updating account data involves a process of sending email with a token and while authorization is done elsewhere, we do not send user ID in the email, thus the need to update the password by email.

You may have read that you should prefer union when the return type is same and function or method overload when the return types differ. As you could see in our example, this is not true.

While you should prefer parameters with union types over overloads whenever possible, according to the typescript documentation about function overloads, it may not be decided by the return types only.

Our example with parameters with union types:

class UserRepository {
  public async update(
    where: UpdateSelfWhere | UpdateEmailWhere,
    data: UpdateSelfData | UpdateEmailData
  ): Promise<void> {
    await this.dataSource.update(User, where, data);
  }
}

All our cases continue to work, so what's the problem? Well, the issue is that you don't enforce a specific combination of where conditions and possible input data anymore, which may not be desired.

class UserService {
  const where = { id: '3198e538-48b5-4f52-9a9f-de788ffc7c1e' };
  const data = { password: '123456' };
  await this.userRepository.update(where, data);
  // ❗ This works but should not because we do not want to update password by the ID which we do not send over email!
}

This code exposes a possibility that will eventually lead to method abuse by a developer who doesn't know the details.

Even if no one abuses the method, it still keeps the intent of functionality hidden and implicit, which simply never helps anyone.

Another, perhaps simpler, example of function overload with different return types:

class UserRepository {
  public async find(id: string): Promise<User | null>;
  public async find(id: string[]): Promise<User[]>;

  public async find(id: any): Promise<(User | null) | User[]> {
    if (Array.isArray(id)) {
      return this.dataSource.findMany(User, { id: In(id) });
    }
    return this.dataSource.findOne(User, { id });
  }
}

With this implementation, you can simply call find() and pass either individual ID (string) or multiple IDs (array). Thanks to different return type definitions in a method declaration, typescript will warn you if you call methods or access members of an object on incorrect type, such as:

class UserService {
  const id = '3198e538-48b5-4f52-9a9f-de788ffc7c1e';
  const user = await this.userRepository.find(id);
  if (user.length) {}
  // ❗ User can be null - this is because you expect either User object or null

  const users = await this.userRepository.find([id]);
  if (user.length) {}
  // ✅ Works now because we passed array of IDs and thus expect array of User objects (which can never be null)
}

Function or method overloading is a powerful typescript feature that allows you to reduce code duplications and encapsulate common functionalities in an individual method.

Beware that if you simply do not need to enforce a specific combination of parameters or have different return types, it is better to use parameters with unions. Finally, function overloading is useful only to a certain extent, and you should aim for a handful of function declarations.

Martin Milo
The Author Martin Milo

Seasoned full-stack developer with years of startup experience at Wonderway.io, now focused on BE architecture and DevX at Become1.de. Pragmatic at building own web and native apps. Writing software-related blog posts and teaching.