Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Randy Brown and Sebastian Zartner
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Randy Brown Sebastian Zartner

Using Method And Function Overloading In TypeScript

By
Published in Comments (11)

UPDATE: In the following post, I state that the final signature doesn't need any typing. However, as Prashant Tiwari pointed out, that was because my tsconfig allowed for implicit "any". Once you disable that, the TypeScript compiler throws an error (and requires some degree of typing on the base method signature).


The other day, I added DogStatsD support to my ColdFusion StatsD library. As part of that implementation, I allowed for a flexible set of method invocation signatures. Unfortunately, in a dynamic language like ColdFusion, method overloading is anything but explicit and the code ends up being a bit messy. The same is true for JavaScript. However, with TypeScript, we can add the clarity of function-overload signatures on top of the aggressively dynamic nature of JavaScript. That said, it took me a little while to figure out just how I was supposed to define the various facets of an overloaded method in TypeScript.

In TypeScript, when you overload a method signature, there is still only one implementation of the method. As such, overloading a method doesn't change the base behavior of the method - it only changes the way in which TypeScript will validate the inputs and return value of the method. This means that our base method needs to accept every combination of inputs - as defined by the overloaded signatures - and manage internal control flow based on arity and explicit type checks.

This really tripped me up at first. If your base method signature doesn't account for one of the overloaded signatures, TypeScript will throw the following error:

Error[TS2394]: Overload signature is not compatible with function implementation.

To get this working, your base method needs to include as many arguments as the overload signature with the most arguments. However, some of those arguments may need to be flagged as optional if not all of the overloaded signatures have the same arity (number of arguments). As a nice trade-off, however, the base method doesn't need to include any type annotations since those have all been accounted for in the overloaded signatures.

To see this in action, I wanted to create a DogStatsD-inspired .count() method that accepts the following invocation patterns:

  • .count( metric, value )
  • .count( metric, value, rate )
  • .count( metric, value, tags )
  • .count( metric, value, rate, tags )

This method is particularly interesting because the 3rd argument can be one of two different types depending on the number of inputs supplied at invocation time. And, it can accept anywhere between 2 and 4 arguments. Luckily, with method overloading in TypeScript, the type annotations add a lot of clarity:

// Require the core node modules.
var chalk = require( "chalk" );

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

class API {

	// When overloading method signatures in TypeScript, we list the overloaded method
	// signatures above a base implementation method. We should list the method
	// signatures in order of specificity since TypeScript will pick the first signature
	// that matches the argument arrangement.
	// --
	// NOTE: In this example, I'm putting the return type on the overload signatures
	// because I'm actually overloading it. However, if I wasn't overloading it, I
	// could omit it from the overloads and include it only as the return type of the
	// base method implementation.
	public count( metric: string, value: number, rate: number, tags: string[] ) : void;
	public count( metric: string, value: number, tags: string[] ) : void;
	public count( metric: string, value: number, rate: number ) : void;
	public count( metric: string, value: number ) : API; // <--- Overloaded return type.

	// The base method implementation is NOT PART OF THE OVERLOAD list. However, it has
	// to be flexible enough to account for all of the above signatures. As such, the
	// 3rd and 4th arguments have to be flagged as optional. If they are not optional,
	// TypeScript will throw the error since it can't account for all of the overloads:
	// --
	// Error: Overload signature is not compatible with function implementation.
	// --
	// Notice, also, that we don't need type definitions on these arguments since the
	// types have all been accounted for in the above signatures.
	public count( metric, value, arg3?, arg4? ) {

		if ( Array.isArray( arg4 ) ) {

			logSignature( "count( metric, value, rate, tags )." );
			logArgument( "metric:", metric );
			logArgument( "value:", value );
			logArgument( "rate:", arg3 );
			logArgument( "tags:", arg4 );

		} else if ( Array.isArray( arg3 ) ) {

			logSignature( "count( metric, value, tags )." );
			logArgument( "metric:", metric );
			logArgument( "value:", value );
			logArgument( "tags:", arg3 );

		} else if ( arg3 !== undefined ) {

			logSignature( "count( metric, value, rate )." );
			logArgument( "metric:", metric );
			logArgument( "value:", value );
			logArgument( "rate:", arg3 );

		} else {

			logSignature( "count( metric, value )." );
			logArgument( "metric:", metric );
			logArgument( "value:", value );
			return( this );

		}

	}

}


var api = new API();

// Let's try invoking the .count() method using each of the 4 overload signatures.
api.count( "page.view", 1 );
api.count( "page.view", 1, 0.5 );
api.count( "page.view", 1, [ "route:view" ] );
api.count( "page.view", 1, 0.5, [ "route:view" ] );

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function logSignature( value: string ) : void {

	console.log( chalk.red.bold( "Using signature:" ), chalk.red( value ) );

}

function logArgument( ...values: any[] ) : void {

	console.log( chalk.dim( " ->", ...values ) );

}

As you can see, the method overload signatures are listed prior to the base implementation, in order of specificity. In this case - it's a silly example - but, one of the invocation patterns changes the return type. This is why I'm including the return type in the overload signatures. If all of the variations returned the same type, I could have put the return type annotation on the base method and omitted it from the overloaded signatures.

Notice also that my base method has two optional arguments: arg3? and arg4?. These optional arguments allow for the base method to be invoked with anywhere between 2 and 4 arguments, which accounts for the overloaded signature variations. Again, if we didn't have these optional arguments, TypeScript would complain about compatibility with our function implementation.

Now, if we run this TypeScript file through ts-node, we get the following terminal output:

Using method and function overloading in TypeScript.

As you can see, we were able to invoke the overloaded method with each of the four accepted signatures. And, by using internal type checks, we were able to control the flow of execution within the base method body.

Now, this isn't quite as clean as a language like Java in which each overloaded method signature gets its own method body. But, the type annotations of the method overloading in TypeScript add a lot of clarity to the dynamic nature of JavaScript. And, since we get to lean on TypeScript for the input validation, our internal control-flow logic only needs to differentiate between signatures - we don't really need to validate all the inputs. That's a subtle but powerful point.

When possible, I try to avoid method overloading since it creates a more complex API, both in terms of consumption, but also in terms of tested and implementation. That said, the type annotations of method and function overloading in TypeScript bring a much-welcomed clarity to the dynamic nature of JavaScript. Just make sure your base method is compatible with all the overloaded variations!

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

This is a nice intro to overloading, but I'm afraid you're able to omit the type annotations for the base method only because you're probably running with strict mode disabled. Turn it on and this will no longer be the case.

15,811 Comments

@Prashant,

Ahhhh, very interesting point. I just looked back at my demo code and in-fact I don't have a tsconfig.json (usually I do). I'll have to go back and add one and see what happens. Much thanks for the feedback.

15,811 Comments

@Prashant,

By the way, you were totally right! With a tsconfig in place, I did get an error about the type. Excellent catch. I had to put :any on the optional arguments.

3 Comments

This is a really nice article. One question though, do you also have an example for function overload, as currently in the example, you show only for methods (function belonging to a class).
I'm interested to overload an actual function and I tried it with an interface but it fails for the return type.

Here's the example:

interface NumberOrStringMethod {
(input: string, type: 'number'): number;
(input: string, type: 'string'): string;
}

const numberOrString: NumberOrStringMethod = (input: string, type: 'string' | 'number'): string | number => {
return type === 'string' ? input : +input;
};

And I get the following error:
Type 'string | number' is not assignable to type 'number'.     Type 'string' is not assignable to type 'number'

15,811 Comments

@Cristi,

That's a great question. I haven't tried that yet -- let me see if I can get this working. Offhand, I would guess that the issue is that you are not providing a base-case for the signature (ie, a signature that takes any or number|string as the second argument). But, I'll will experiment and report back.

3 Comments

@Ben,

The issue seems to be on the return type and not the parameters, because if I implement the method with return type any, it will work:

const numberOrString: NumberOrStringMethod = (input: string, type: 'string' | 'number'): any => { return type === 'string' ? input : +input; };
But this way, I'm required to be extra careful with the return value inside the function implementation.
Please let me know of you progress when you have time to play with this.

Thanks in advance!

15,811 Comments

@Cristi,

So, I think the problem is that the inline function doesn't actually adhere to the Interface. Meaning, the Interface strictly maps the number to number and the string to string. But, the function implementation you provide isn't actually doing that. It's only saying that you can accept either a string or a number and you return either a string or a number - it doesn't guarantee that you'll return a string if you get a string or that you'll return a number if you get a number. Intuitively, we know that's what it's doing when we look at the implementation; but, the TypeScript compiler doesn't know that.

If we take your approach and convert it to a Function definition with the proper signature overloads, things do work:

interface Callback {
	( value: string ): string;
	( value: number ): number;
}

function myCallback( value: string ) : string;
function myCallback( value: number ) : number;
function myCallback( value: number | string ) : number | string {

	if ( typeof ( value ) === "string" ) {

		return( value + value );

	} else {

		return( value * value );

	}

}


// Try to assign the myCallback implementation to the Callback interface.
var test: Callback = myCallback;

console.log( test( "foo" ) );
console.log( test( 4 ) );

Here, I'm assigning my implement, myCallback to a variable of type Callback. Of course, at this point, we lose the fat-arrow notation (and the this binding). I think we can approach this from a different angle. Let me try something.

15,811 Comments

@Cristi,

Ultimately, I think the problem is that we keep thinking about the problem from what we know is going to happen at runtime; but, the TypeScript compiler doesn't know about runtime -- it only knows about what it can infer at compile time. So, even if we know that we'll only pass a callback of a certain signature at runtime, TypeScript can only infer from the types provided in the signature.

What I mean is that I think TypeScript is working as expected.

3 Comments

@Ben,

function myCallback( value: string ) : string; function myCallback( value: number ) : number; function myCallback( value: number | string ) : number | string { // function implementation }
This is exactly what I was looking for, I did not know about this syntax. It's not mandatory for me to use arrow functions with interfaces, my example was that way because it was the only way I knew about it (trying to overload a function).
Regarding the runtime functionality, yes, the function implementation must be made in the way that returns the correct value and typescript does not check if it's correct. But at least it limits you to return type of number| string instead of any.

That you for your time and assistance, it helped become a better developer. :)

15,811 Comments

@Cristi,

Very cool! Glad this has been helpful :D With function overloading, you essentially need to have all the specific use-cases followed by a single "base case", which must account for all of the specific use-cases. And, for what its worth, you can overload any method, even a class constructor.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel