For the majority of my journey in software development, I have used languages that have supported method overloading. I have found this to be useful, so I have been wondering how to implement something similar in TypeScript. How can we have a method can be declared multiple times with different parameters in a language which does not support method overloading in the traditional way?
After posting, it was brought to my attention that there is another way. I have added an update at the end to cover this approach.
The Problem
I wanted to create a class to build queries for DynamoDB. DynamoDB is a NoSQL database that indexes each item by two keys. A partition key and a sort key. When querying a DynamoDB table you always supply a partition key, and you optionally supply a sort key along with an operator such as 'greater than'. Another option is to supply two sort key values to provide a range.
The C# Solution
In C#, we could define as follows:
enum SortKeyOperator
{
EQUALS,
LESS_THAN,
LESS_THAN_OR_EQUAL,
GREATER_THAN_OR_EQUAL,
GREATER_THAN,
BEGINS_WITH,
}
class QueryBuilder
{
public void Build(string partitionKeyValue)
{
}
public void Build(
string partitionKeyValue,
string sortKeyValue)
{
}
public void Build(
string partitionKeyValue,
SortKeyOperator sortKeyOperator,
string sortKeyValue)
{
}
public void Build(
string partitionKeyValue,
string sortKeyFromValue,
string sortKeyToValue)
{
}
}
This is allowed, as the combination of parameters means that each method signature is unique, even though the name of the method is not. When using the Visual Studio IDE the intellisense prompts as follows.
This allows the user to scroll through the various overloaded versions of the method.
By adding documentation to the methods, you can clearly communication the intended use of each overload.
TypeScript Attempt No.1 - Separate Methods
The simplest way I could think of to try to replicate method overloading is to have separate methods that share a common prefix. In this case, buildWith
. The result is as follows:
class QueryBuilder {
buildWithPartitionKeyOnly(partitionKeyValue: string) {...}
buildWithSortKey(partitionKeyValue: string, sortKeyValue: string) {...}
buildWithComparison(
partitionKeyValue: string,
sortKeyOperator: SortKeyOperator,
sortKeyValue: string
) {...}
buildWithRange(
partitionKeyValue: string,
sortKeyFromValue: string,
sortKeyToValue: string
) {...}
}
This would result in the following prompt when using VS Code:
I actually think this approach has some merit. The explicit naming provides some level of self-documentation. A downside is that the underlying implementation might need either some duplication in the separate methods, or some common code outside them.
TypeScript Attempt No.2 - Optional Parameters
Another approach is to use optional parameters and deconstructed parameters. We can define a single method with a single object parameter, and we can make the sort key parameters all optional. The result is as follows:
build({
partitionKeyValue,
sortKeyValue,
sortKeyComparison,
sortKeyRange,
}: {
partitionKeyValue: string;
sortKeyValue?: string;
sortKeyComparison?: {
operator: SortKeyOperator;
value: string;
};
sortKeyRange?: {
fromValue: string;
toValue: string;
};
}) {
if (sortKeyValue) {
// Handle case where we match by value equality
} else if (sortKeyComparison) {
// Handle case where we match by comparison
} else if (sortKeyRange) {
// Handle case where we match by range
} else {
// Handle case where we match just by primary key
}
}
Whilst this works, it isn't obvious to the caller what combination of parameters should be used to get the various outcomes. For example, can sortKeyRange
be used with sortKeyValue
? The only way to know this, is to look inside the method. Not ideal. Can we do better?
TypeScript Attempt No.3 - Naive Discriminated Types
TypeScript allows you to define that a value can be one of a set of types, for example:
var v: number | string;
Can we take advantage of this to give the callers of the method a set of exclusive choices, so that they do not need to look inside the method to work out how to use it?
Below was my first effort:
build({
partitionKeyValue,
sortKeyCriteria,
}: {
partitionKeyValue: string;
sortKeyCriteria?:
| {
value: string;
}
| {
comparison: {
operator: SortKeyOperator;
value: string;
};
}
| {
range: {
fromValue: string;
toValue: string;
};
};
}) {
if (sortKeyCriteria) {
if ('value' in sortKeyCriteria) {
// Handle case where we match by value equality
} else if ('comparison' in sortKeyCriteria) {
// Handle case where we match by comparison
} else if ('range' in sortKeyCriteria) {
// Handle case where we match by range
} else {
}
} else {
// Handle case where we match just by primary key
}
}
Here we use the in
operator to work out which of the types has been specified. This all seemed to be working as I expected until I tried the following:
queryBuilder.build({
partitionKeyValue: "pk",
sortKeyCriteria: {
value: "sortKeyValue",
range: {
fromValue: "sortKeyValue1",
toValue: "sortKeyValue2",
},
comparison: {
operator: SortKeyOperator.GREATER_THAN,
value: "sortKeyValue",
},
},
});
I was expecting a compiler error, as I had specified all three options. However, clearly TypeScript does not work that way. What I had I done wrong?
TypeScript Attempt No.4 - Discriminated Types Done Properly
The solution came from an example in the TypeScript playground. What I needed to do was define a value that would discriminate the types. The result is as follows:
build({
partitionKeyValue,
sortKeyCriteria,
}: {
partitionKeyValue: string;
sortKeyCriteria?:
| {
type: 'value';
value: string;
}
| {
type: 'comparison';
operator: SortKeyOperator;
value: string;
}
| {
type: 'range';
fromValue: string;
toValue: string;
};
}) {
if (sortKeyCriteria?.type === 'value') {
// Handle case where we match by value equality
} else if (sortKeyCriteria?.type === 'comparison') {
// Handle case where we match by comparison
} else if (sortKeyCriteria?.type === 'range') {
// Handle case where we match by range
} else {
// Handle case where we match just by primary key
}
}
Now when using the class in VS Code, when you select the type
you get the corresponding options. For example, when using range
you get prompted for the relevant fromValue
and toValue
values.
Now we have a single method that presents a set of mutually exclusive choices. The caller cannot get the parameters wrong and doesn't need to look inside the method.
Documentation
One downside to using deconstructed parameters is that I could not find a way to document them well using JSDoc. The best I could come up with was the following.
/**
* Builds a query based on the key criteria supplied.
* @param param0 Key criteria
*/
build({ partitionKeyValue, sortKeyCriteria }: {...}) {...}
This resulted in the following prompt in VS Code, which does give some indication of the options via the type
values.
I may have been missing something, but for this reason I found myself quite liking the solution with separate methods. That approach was easy to document and also somewhat documented itself with the verbose names.
Here we can see that the individual parameters can be documented in the same way that they can be in the C# example, as shown below.
Summary
In this post we looked at various ways that we can implement some form of method overloading in TypeScript and compared these with an equivalent in C#. My feeling is that for internal libraries, I would favour the discriminated type approach. However, for external libraries, I feel that the ability to fully document means that the simple, multi-named approach would be better. Behind the scenes, these methods may map onto a single discriminated type method, in order to keep functionality together.
Update
After posting this, it was pointed out to me that there is another way that we can implement method overloading in TypeScript.
The way we can do it is by defining an args
parameter with the JavaScript spread syntax and a list of possible parameters. In our example this would be the following:
build(
...args:
| [partitionKeyValue: string]
| [partitionKeyValue: string, sortKeyValue: string]
| [
partitionKeyValue: string,
sortKeyOperator: NumericSortKeyOperator,
sortKeyValue: string
]
| [
partitionKeyValue: string,
sortKeyFromValue: string,
sortKeyToValue: string
]
) {...}
In VS Code, this gives an experience very similar to that we had for C#:
We still have the problem of how to know which overload is being called. I solved this by building up a signature string from the arguments types and switching on the result.
const signature = args
.map((arg) => typeof arg)
.reduce((accumulator, argType) => `${accumulator}${argType}|`, '|');
switch (signature) {
case '|string|':
// Handle case where we match by partition key only
break;
case '|string|string|':
// Handle case where we match by compound key
break;
case '|string|string|string|':
// Handle case where we match by range
break;
case '|string|number|string|':
// Handle case where we match by comparison
break;
default:
throw new Error(`Unhandled signature`);
}
One thing I did have to change was the type of enum
. Originally, it was a set of strings, but this would cause a clash of signatures. I changed it for a set of integers and this avoided the issue.
I used array destructuring to access the values as follows:
case '|string|number|string|':
// Handle case where we match by comparison
{
const [partitionKeyValue, sortKeyOperator, sortKeyValue] = args;
// Call the method implementation
}
break;
Whilst TypeScript does infer the types, it does not discriminate. So it can only assert that some values are one of a set:
This approach suffers from the documentation issue that other advanced approaches do. My feeling overall is that, although it gives a similar intellisense experience, it falls down when implementing the underlying functionality and I would still be tempted to go down the explicit naming route with discriminated types underneath.