3 Ways To Write Function Overloads With JSDoc & TypeScript

Austin Gil - Oct 21 '21 - - Dev Community

I like TypeScript, but I prefer the JSDoc syntax for writing it. That should be obvious if you’ve read any of my JavaScript articles, especially Get Started With TypeScript the Easy Way.

So far, I haven’t run into any scenarios where I can’t use JSDoc to accomplish the same functionality as TypeScript.

It wasn’t until I needed to implement the type definition for JavaScript function overloads that I seriously started to question that.

What Is Function Overloading?

Feel free to skip this if you already know, but if you don’t let’s first understand what function overloading is.

Function overloads are when you define the same function more than once in order to capture different functionality.

Here’s a contrived example. Let’s say we wanted to create a function called double. It takes one parameter. If the parameter is a number, it would multiply it by two and return the result. If it’s a string, it will concatenate that string to itself and return that.

It’s a silly example, but it might look like this:

function double(input) {
  return input * 2
}
function double(input) {
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

Beautiful!

There’s just one problem. JavaScript doesn’t support function overloading. Instead, we’d have to do something like this:

function double(input) {
  if (typeof input === 'number') {
    return input * 2
  }
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

A Naive Solution

If we want to write the type definition for this function, it’s a little complicated. We know the input can be either a string or a number, and the output is kind of the same thing.

We could accomplish that with a “union” type. Unions allow us to define a type as being either “this” or “that”. In our case, either a string or a number.

Here we’ll use JSDoc’s @param and @returns keywords to assign the input and output to a union of string and number.

/**
 * @param {string | number} input 
 * @returns {string | number}
 */
function double(input) {
  if (typeof input === 'number') {
    return input * 2
  }
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that no matter what, when we call our function, we will always get back a union.

 raw `const myVar = double(2)` endraw  with the type definition  raw `const myVar: string | number` endraw

What we really want is to get back one specific type based on what the input is. That’s where function overloads come in.

Defining Function Overloads In JSDoc

TypeScript already has documentation dedicated to function overloads. In their example, they show how a simple function can be documented:

function add(a:string, b:string):string;

function add(a:number, b:number): number;

function add(a: any, b:any): any {
  return a + b;
}

add("Hello ", "Steve"); // returns "Hello Steve" 
add(10, 20); // returns 30 
Enter fullscreen mode Exit fullscreen mode

Notice how the same function is defined three times. Twice for the type definitions, and once for the functionality.

Unfortunately, the same cannot be said for JSDoc.

After a lot of searching, I finally found a solution that seems to work well enough. We can define a variable whose value is an an anonymous function. Just above that variable definition, we can use JSDocs @type keyword to define the type for that variable, and within that type definition, we can describe our function overloads.

In this case, we want to describe two arrow functions. One that takes an input with a type of number and whose return type is a number, and one that takes an input with a type of string and whose return type is a string:

/**
 * @type {{
 * (input: number) => number;
 * (input: string) => string;
 * }}
 */
const double = (input) => {
  if (typeof input === 'number') {
    return input * 2
  }
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

The above example uses an arrow function. That may not be appropriate for scenarios where scope is a concern. Fortunately, we can accomplish the same with a function expression:

/**
 * @type {{
 * (input:number) => number;
 * (input:string) => string;
 * }}
 */
const double = function(input) {
  if (typeof input === 'number') {
    return input * 2
  }
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

As a result, we will see different type definitions for our functions based on whether the input is a number or a string.

When our input is a number, our function’s type definition shows that it returns a number.

 raw `const myVar = double(2)` endraw  with the type definition  raw `const double: (input: number) => number (+1 overload)` endraw

When our input is a string, our function’s type definition shows that it returns a string.

 raw `const myVar = double('hi')` endraw  with the type definition  raw `const double: (input: string) => string (+1 overload)` endraw

More importantly, we get the desired results for our variable’s type definition. When the input is a number, our variable is a number.

 raw `const myVar = double(2)` endraw  with the type definition  raw `const myVar: number` endraw

When the input is a string, our variable is a string.

 raw `const myVar = double('hi')` endraw  with the type definition  raw `const myVar: string` endraw

The syntax for defining function overloads is a bit strange, but it works well enough in practice. The only caveat I found is that it relies on assigning the function to a variable. It does not work with function declarations (ie, function double(input) { /* ... */ }).

To be honest, I can’t think of a scenario where you must use a function declaration and cannot use a function expression, but if you really need a solution for that, there is a workaround.

TypeScript also offers generics which you can combine with conditional types to determine the input type and conditionally return a specific type based on what the input is. All of that can even work with JSDoc thanks to the @template keyword (which is not well documented).

Applying that to our example above would look like this:

/**
 * @template numOrStr
 * @param {numOrStr} input
 * @returns {numOrStr extends number ? number : string}
 */
function double(input) {
  if (typeof input === 'number') {
    return input * 2
  }
  return input + input
}
Enter fullscreen mode Exit fullscreen mode

We define our generic as numOrString, apply that as the input type, then in the return type, we check whether the input type extends a number type. If it does, the return value is a number type. If not, it’s a string type.

Closing

TypeScript is great, and JSDoc is great, but once in a while, the documentation for complex things is sparse.

I wrote this article because I’m sure that I will need to look up how to do this in the future and I’d rather not go through that whole treasure hunt again. Instead it can live all in one place here, and maybe help out other folks like you.

We covered a lot of complex TypeScript things like unions, overloads, generics, and dynamic types. I hope I was able to explain them in a clear way. And if you’re new to TypeScript, I’d recommend going to read my beginners guide.

Thank you so much for reading. If you liked this article, please share it, and if you want to know when I publish more articles, sign up for my newsletter or follow me on Twitter. Cheers!


Originally published on austingil.com.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .