Every polymorphism in TypeScript

Adrien Gautier
4 min readFeb 19, 2024
Background picture by Milad Fakurian

While writing about function overloading in TypeScript, I stumbled upon the existence of three major types of polymorphism in programming theory. In this article, I’ll introduce these three types of polymorphism in TypeScript using simple examples.

Parametric polymorphism

If you use generic functions in TypeScript, you may have used this pattern without knowing. Consider the following function of dubious utility:

function arrayEntries<T>(items: Array<T>): Array<[number, T]> {
return items.map((item, index) => [index, item]);
}

The arrayEntries function is called “generic” because its input type is not known during declaration. When called, it will infer the type of the returned data.

const entries = arrayEntries(['one', 'two']); // [[0, 'one'], [1, 'two']]

In the above example, the constant entries will receive the type Array<[number, string]> according to the string items in argument.

“Generic functions” is just another name of parametrically polymorphic functions. Consequently, I believe generic types, like Array<T>, are also a form of parametric polymorphism, but don’t take my word for it.

Subtype polymorphism

This one is maybe less common in TypeScript, because it relies on classes:

interface Pet {
speak: () => void;
}

class Dog implements Pet {
speak() {
return 'woof woof!';
}
}

class Cat implements Pet {
speak() {
return 'meow!';
}
}

In this example, both Dog and Cat are subtypes of Pet. We can create a listen function that accepts any subtypes of Pet:

function listen(pet: Pet): void {
console.log(pet.speak());
}

This is called subtype polymorphism, as the speak method is available for any class that is a subtype of Pet, and its behavior can be unique to each class.

Ad hoc polymorphism

This is what I call “polymorphic functions”. Or rather, Ad hoc is the term used by Christopher Strachey to classify this concept also known as function overloading.

Ad hoc polymorphism differs from parametric and subtypes polymorphism in that its behavior depends on its arguments. That means a specific implementation will be called according to the arguments’ type.

Function overloading in C++

The following C++ example, taken from Wikipedia, illustrates the coexistence of different implementations (and therefore different behaviors) for the same Volume function:

int Volume(int s) {  // Volume of a cube.
return s * s * s;
}

double Volume(double r, int h) { // Volume of a cylinder.
return 3.1415926 * r * r * static_cast<double>(h);
}

long Volume(long l, int b, int h) { // Volume of a cuboid.
return l * b * h;
}

int main() {
std::cout << Volume(10);
std::cout << Volume(2.5, 8);
std::cout << Volume(100l, 75, 15);
}

Function overloading in TypeScript

In TypeScript, the approach is a little bit different. Using the same example, we will start by writing one declaration (also called overload signature) for each behavior of the Volume function. I usually include a JSDoc comment with each declaration:

/**
* Calculates the volume of a cube.
* @param {number} s - The length of the side of the cube.
* @returns {number} The volume of the cube.
*/
function Volume(s: number): number;

/**
* Calculates the volume of a cylinder.
* @param {number} r - The radius of the cylinder's base.
* @param {number} h - The height of the cylinder.
* @returns {number} The volume of the cylinder.
*/
function Volume(r: number, h: number): number;

/**
* Calculates the volume of a cuboid.
* @param {number} l - The length of the cuboid.
* @param {number} w - The width of the cuboid.
* @param {number} h - The height of the cuboid.
* @returns {number} The volume of the cuboid.
*/
function Volume(l: number, h: number, b: number): number;

Then we declare the actual implementation of the function:

function Volume(d: number, h?: number, b?: number): number {
if (h !== undefined && b !== undefined) {
return d * h * b;
}
if (h !== undefined) {
return Math.PI * d * d * h;
}
return d * d * d;
}

Unlike in C++, a single implementation is responsible for the different behaviors of the Volume function. Here, it is the if statements that conditions the behavior of the function, depending on the number of arguments supplied.

console.log(Volume(10)); // Logs the volume of a cube.
console.log(Volume(2.5, 8)); // Logs the volume of a cylinder.
console.log(Volume(100, 75, 15)); // Logs the volume of a cuboid.

Polymorphisn’t

With this example, we saw that a single implementation is used in conjunction with multiple declarations. This is very different from other languages, where each behavior has its own implementation. So can it really be called ad hoc polymorphism ?

While I can’t give a definitive answer to this question, I can highlight two advantages of using the function overloading approach with TypeScript.

In the previous example, the function overloading constraints how to use the Volume function. It is not possible to call the function like this:

Volume(100, undefined, 15); // this won't build

This would be possible with the function implementation alone, but the overload signatures prevent it. Also, thanks to the JSDoc comments, every way of using the function is clearly indicated by your IDE:

Example of the suggestions provided by the IDE

Regardless of how function overloading is implemented in TypeScript, I think it can improve code quality and avoid errors.

Summary

We’ve seen how parametric polymorphism, subtype polymorphism and ad hoc polymorphism are implemented in TypeScript and how function overloading differs from other programming languages. In my next article, I’ll explore in more detail the limitations of function overloading in TypeScript and some alternative solutions we can find to implement polymorphic functions.

--

--