Using Method And Function Overloading In TypeScript

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:

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!

Tweet This Provocative thoughts by @BenNadel - Using Method And Function Overloading In TypeScript Woot woot — you rock the party that rocks the body!







