Function overloads for nicer code

December 15th 2023 TypeScript

Since JavaScript is not a strongly typed language, it doesn't support function overloading. However, you might not be aware that TypeScript provides limited support for function overloading on top of existing JavaScript syntax. Still, it might make your functions easier to use from client code in certain scenarios.

Let's start with the following data structure for achievements in games:

export interface Achievement {
  title: string;
  description: string;
  points: number;
  secret: boolean;
  unlockRatio: number;
}

And the following function performing some kind of operation on it:

export function calculateAdjustedScore(achievement: Achievement): number {
  return achievement.points * Math.sqrt(1 / achievement.unlockRatio);
}

It's reasonable to expect that achievements will be grouped by games:

export interface Game {
  title: string;
  achievements: Achievement[];
}

Imagine only knowing the name of the achievement in a certain game and having to invoke the calculateAdjustScore function. Of course, you can always find the right achievement before calling the function:

const achievementName = "Water piper";
const achievement = jusant.achievements.find(
  (achievement) => achievement.title === achievementName
);
const adjustedScore = calculateAdjustedScore(achievement!);

However, if you need to do that often, it makes sense to put this code in a function. In a strongly-typed language, you would use a function overload for that. In JavaScript, you could create another function with a different name and a different set of parameters which calls the original function after finding the achievement:

export function calculateAdjustedScoreForAchievementName(
  game: Game,
  achievementName: string
): number {
  const achievement = game.achievements.find(
    (achievement) => achievement.title === achievementName
  );

  if (!achievement) {
    throw new Error("Achievement not found");
  }

  return calculateAdjustedScore(achievement!);
}

This approach can make it a challenge to find a good name for the new function: one that will make sense to you and also to other developers, who will need to find it.

Alternatively, you could change the signature of your original method to accept both sets of parameters and act accordingly for each one:

export function calculateAdjustedScore(
  achievementOrGame: Achievement | Game,
  achievementName?: string
): number {
  const achievement = isGame(achievementOrGame)
    ? achievementOrGame.achievements.find(
        (achievement) => achievement.title === achievementName
      )
    : achievementOrGame;

  if (!achievement) {
    throw new Error("Achievement not found");
  }

  return achievement.points * Math.sqrt(1 / achievement.unlockRatio);
}

Union types and optional parameters make it possible to describe types for such a function in TypeScript. Inside the function, you still need to determine which set of parameters was passed to it. A type predicate can be used for that:

function isGame(
  achievementOrGame: Achievement | Game
): achievementOrGame is Game {
  return (achievementOrGame as Game).achievements !== undefined;
}

If the object has the achievements property, we know that it is a Game and not an Achievement. We can rely on TypeScript that it won't allow any other types to be passed in as the parameter.

Such functions are a common approach in JavaScript, and can often work well on their own. However, in this particular case, The function signature doesn't fully describe the two different sets of parameters that the function expects. It only specifies that the second parameter is optional. The actual expectations are:

  • When the first parameter is a Game, the second parameter is required.
  • When the first parameter is an Achievement, the second parameter shouldn't be provided as it will be ignored.

We can describe that using TypeScript's function overloads:

export function calculateAdjustedScore(
  game: Game,
  achievementName: string
): number;
export function calculateAdjustedScore(achievement: Achievement): number;
export function calculateAdjustedScore(
  achievementOrGame: Achievement | Game,
  achievementName?: string
): number {
  const achievement = isGame(achievementOrGame)
    ? achievementOrGame.achievements.find(
        (achievement) => achievement.title === achievementName
      )
    : achievementOrGame;

  if (!achievement) {
    throw new Error("Achievement not found");
  }

  return achievement.points * Math.sqrt(1 / achievement.unlockRatio);
}

In addition to the actual (implementation) function signature, we can add multiple overload signatures above it. Only these overload signatures will be available for the client code to use. On the other hand, the implementation signature is used to describe a full set of parameters that matches all the overload signatures, as the function implementation can expect them. In the implementation, we can assume, that the function will not be called with values that don't match any of the overload signatures even if they would match the implementation signature (e.g., a Game without an achievementName in our case).

You can find full code for this example in my GitHub repository. Each commit represents a step from finding the achievement outside the function to the final function with overloads, as they are presented in this post.

Although JavaScript doesn't support function overloading, you shouldn't fully ignore that functionality in TypeScript. While it might not always be appropriate, it can make a function more intuitive when used correctly.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License