Skip to main content
Published in Engineering Insights

Overloading Functions for Type Harmony

Overloading Functions for Type Harmony
Table of contents
  1. A Strange Program
  2. Enter: Overloads
  3. A Little Bit of a Tangent (About Overloads and Return Types) but stick with me here
  4. Highway to the Overload

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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
+ 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:

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

Becomes This:

TypeScript
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.

TypeScript
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.

TypeScript
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.

Learn more about how The Gnar builds software.

Frequently asked questions

Why does adding a number to the original addTwo result fail?

Its return type is string | number. Typescript does not know that a number input will produce a number output.

What does overloading a function mean?

It means giving a function more than one signature. The example puts separate string and number signatures above the implementation.

Which signatures do callers see?

Callers see the overload signatures. The implementation signature is not exposed to consumers.

Stay In The Loop

Never miss an update. Get the latest blogs, webinars, and expert insights straight to your inbox from The Gnar.