Overloading Functions for Type Harmony

  • February 22, 2022
  • Alex Jarvis
  • 5 min read

Recently, I decided to brush off an old project and tune it up with some modern day Typescript know-how. In my weeks-long dice-driven fugue state, I stumbled on some Typescript features that I hadn't had a chance to use before, and I think they're neat.

A Strange Program

Let's imagine that you have been asked to write a very interesting, if a little curious, program. Your task is to write a program that will take either a number (4), or a number-like string ("4"). It's then going to add 2 to that number, and return the total. Stranger still, the type of the return value must match the type of the value that you put into the system in the first place.

How would we go about writing something like that? Maybe something like this:

export function addTwo(baseNumber: string | number): string | number {
  const baseNumberValue = Number(baseNumber)

  if (Number.isNaN(baseNumberValue)) {
    throw new TypeError('baseNumber must be number or number-like string')
  }

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

Running the program, we see that it works:

addTwo('2') // '4'
addTwo(2) // 4
addTwo(2) // 4

What happens if we try to use this elsewhere? Then we get into trouble.

For instance, this line will not work:

addTwo(2) + 4 // Type Error: Operator '+' cannot be applied to types 'string | number' and 'number'.

This is because the return type of addTwo is a union type - just like the baseNumber argument. That means that Typescript lacks the understanding that we contextually know just by reading the code: if it's a string coming in, it's a string coming out.

Enter: Overloads

By re-defining the function signature above the function, we can let Typescript know that there are more specific ways of calling this particular function. This is called "overloading" the function - giving a function more than one function signature. We can construct the overload like so:

export function addTwo(baseNumber: string): string
export function addTwo(baseNumber: number): number
export function addTwo(baseNumber: string | number) {
  const baseNumberValue = Number(baseNumber)

  if (Number.isNaN(baseNumberValue)) {
    throw new TypeError('baseNumber must be number or number-like string')
  }

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

The first two changes are the overloads, which spell out the consequences of using one type versus the other. On top of the obvious compiler usability benefits, I really like how this spells it out to any developers who might come across this very odd little function in the future.

On top of that, we've also made a change to the implementation function signature by removing the explicit type returns. While I generally like being explicit about return types, this is one instance where being too explicit hurts us.[1]

A Little Bit of a Tangent (About Overloads and Return Types) but stick with me here

By explicitly returning the type from this overloaded function's implementation signature, we are clobbering our local Typescript's ability to help us catch problems. Externally, the implementation signature isn't broadcast to any consumers - only the overload signatures are. However, if we aren't careful, we can make a liar out of ourselves.

Let's imagine our function, with explicit return values in our implementation signature:

+ export function addTwo(baseNumber: string): string
+ export function addTwo(baseNumber: number): number
+ export function addTwo(baseNumber: string | number): string | number {
- export function addTwo(baseNumber: string | number) {
  const baseNumberValue = Number(baseNumber)

  if (Number.isNaN(baseNumberValue)) {
    throw new TypeError('baseNumber must be number or number-like string')
  }

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

and now, let's imagine that we accidentally flip the script on that ternary condition on the end - we delete it, and accidentally screw up our cases. Now, we return a string for a number, and a number for a string.

This:

return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal

Becomes This:

return typeof baseNumber === 'number' ? String(numericTotal) : numericTotal

Ideally, we'd want Typescript to scream at us - "Hey! Your code can never match these type signatures - stop!" but it never does. This is because internally - when writing the function, in our editors - Typescript looks to the implementation signature for its return data. It is only about to get as specific as its explicit return value allows, and even though we've swapped the cases on the logic gate, it still reads as number | string. This stinks for us, but it stinks extra hard for anyone consuming this function. Because of our explicit-type-clobbering, we've actually made Typescript tell a lie, and it will result in an error that we won't find until runtime.

This is Typescript. We are better than that.

By removing the explicit return, we let the overloads take full responsibility for reporting their validity to us, the developer. This makes sense, if you think about it - explicit return types are for communicating what Typescript can't assume on its own (or for being friendly to future developers and clear with intent for yourself). You are still explicitly returning a type value here - you're just doing it in the overloads, which is the only thing that gets broadcast to consumers of the function anyway!

Highway to the Overload

By leveraging specificity in our Typescript, we can nudge and shove future developers towards a deeper understanding of the impact of our code, and let Typescript make sure that we aren't bumping into things along the way.

addTwo(2) + 4 // 8 - no Type errors here!
addTwo('2') + ' Letter Word' // '4 Letter Word'
addTwo('2') + 4 // Type Error - no go, Buster

Sometimes, the best documentation is the kind that reads you.

And I like our little function! It's strange - and I can't for the life of me imagine what practical use it could have - but I like it, and I wouldn't change a thing.

export function addTwo(baseNumber: string): string
export function addTwo(baseNumber: number): number
export function addTwo(baseNumber: string | number) {
  const baseNumberValue = Number(baseNumber)

  if (Number.isNaN(baseNumberValue)) {
    throw new TypeError('baseNumber must be number or number-like string')
  }

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

... ... ...

Okay. Maybe a few things. Next up: Let's get rid of that TypeError with the power of template literals!

[1] This section is true as of Typescript 4.5.4.