A simple pattern for method overloading in TypeScript
I was recently building some helper classes in TypeScript, and those classes could be called in a variety of ways.
Many functions have different signatures that are compatible, largely through optional parameters.
function example(foo: string, bar?: number, baz?: boolean) { ... }
For the helper classes I was writing, the different signatures weren't compatible, and required some branching logic in order to support the different scenarios.
Fortunately, TypeScript supports function overloading for cases where the argument types aren't necessarily compatible.
As an example, a Point
object might be constructed without arguments (Point()
), with a single object as an argument (Point(obj)
), or with x
and y
components (Point(2, 5)
)
In order to support these cases, I used to write the overloads with a final generic signature that took an array of unknown values:
class Point {
constructor();
constructor(obj: { x: number; y: number });
constructor(x: number, y: number);
constructor(...args: unknown[]) {
// ...
}
}
This would result in needing to cast args
in order to get the arguments to use the correct types:
switch (args.length) {
case 0:
// do the default behavior
break;
case 1:
const [obj] = args as [{ x: number; y: number }];
// do the object behavior
break;
default:
const [x, y] = args as [number, number];
// do the x,y component behavior
break;
}
I didn't particularly like adding the types in each case because changing the overload signature might not be reflected in the method body.
As an example, making the object type more permissive by allowing strings as well as numbers wouldn't cause an error when the unknown array is explicitly cast to the old type:
constructor({x: number | string, y: number | string});
constructor(...args: unknown[]) {
switch (args.length) {
case 1:
// TypeScript doesn't know that this is an incompatible type
const [obj] = args as [{x: number, y: number}];
break;
}
}
Instead, the trick I found to make overloaded types behave nicer is to use an arguments array with a union type:
class Point {
constructor();
constructor(obj: {x: number, y: number});
constructor(x: number, y: number);
constructor(...args: [] | [{x: number, y: number}], | [number, number]) {
// ...
}
}
That way the TypeScript compiler could detect whether the individual method overloads matched the unified signature. And when they are a valid match, TypeScript could successfully infer the types of the arguments in-context.
switch (args.length) {
case 0:
// do the default behavior
break;
case 1:
const [obj] = args;
// do the object behavior
break;
default:
const [x, y] = args;
// do the x,y component behavior
break;
}