Typescript Generics Explained

Ross Bulat
Apr 20 · 11 min read

Generics: the ability to abstract types

The implementation of generics in Typescript give us the ability to pass in a range of types to a component, adding an extra layer of abstraction and re-usability to your code. Generics can be applied to functions, interfaces and classes in Typescript.

This talk will explain what generics are and how they can be used for these items, visiting a range of viable use cases along the way to further abstract your code.

The Hello World of Generics

To demonstrate the idea behind generics in simple terms, consider the following function, identity(), that simply takes one argument and returns it:

function identity(arg: number): number {
return arg;
}

Our identity function’s purpose is to simply return the argument we pass in. The problem here is that we are assigning the number type to both the argument and return type, rendering the function only usable for this primitive type — the function is not very expandable, or generic, as we would like it to be.

We could indeed swap number to any, but in the process we are losing the ability to define which type should be returned, and dumbing down the compiler in the process.

What we really need is identity() to work for any specific type, and using generics can fix this. Below is the same function, this time with a type variable included:

function identity<T>(arg: T): T {
return arg;
}

After the name of the function we have included a type variable, T, in angled brackets <>.T is now a placeholder for the type we wish to pass into identity, and is assigned to arg in place of its type: instead of number, T is now acting as the type.

Note: Type variables are also referred to as type parameters and generic parameters. This article opts to use the term type variables, coinciding with the official Typescript documentation.

T stands for Type, and is commonly used as the first type variable name when defining generics. But in reality T can be replaced with any valid name. Not only this, we are not limited to only one type variable — we can bring in any amount we wish to define. Let’s introduce U next to T and expand our function:

function identities<T, U>(arg1: T, arg2: U): T {
return arg1;
}

Now we have an identities() function that supports two generic types, with the addition of the U type variable — but the return type remains T. Our function is now clever enough to adopt two types, and return the same type as our arg1 parameter.

But what if we wanted to return an object with both types? There are multiple ways we can do this. We could do so with a tuple, providing our generic types to the tuple like so:

function identities<T, U> (arg1: T, arg2: U): [T, U] {
return [arg1, arg2];
}

Our identities function now knows to return a tuple consisting of a T argument and a U argument. However, you will likely in your code wish to provide a specific interface in place of a tuple, to make your code more readable.

Generic Interfaces

This brings us on to generic interfaces; let’s create a generic Identities interface to use with identities():

interface Identities<V, W> {
id1: V,
id2: W
}

I have used V and W as our type variables here to demonstrate any letter (or combination of valid alphanumeric names) are valid types — there is no significance to what you call them, other then for conventional purposes.

We can now apply this interface as the return type of identities(), amending our return type to adhere to it. Let’s also console.log the arguments and their types for more clarification:

function identities<T, U> (arg1: T, arg2: U): Identities<T, U> {   console.log(arg1 + ": " + typeof (arg1));
console.log(arg2 + ": " + typeof (arg2));
let identities: Identities<T, U> = {
id1: arg1,
id2: arg2
};
return identities;
}

What we are doing to identities() now is passing types T and U into our function and Identities interface, allowing us to define the return types in relation to the argument types.

Note: If you compile your Typescript project and look for your generics, you will not find any. As generics are not supported in Javascript, you will not see them in the build generated by your transpiler. Generics are purely a development safety net for compile time that will ensure type safe abstraction of your code.

Generic Classes

We can also make a class generic in the sense of class properties and methods. A generic class ensures that specified data types are used consistently throughout a whole class. For example, you may have noticed the following convention being used in React Typescript projects:

type Props = {
className?: string
...
};
type State = {
submitted?: bool
...
};
class MyComponent extends React.Component<Props, State> {
...
}

We are using generics here with React components to ensure a component’s props and state are type safe.

Class generic syntax is similar to what we have been exploring thus far. Consider the following class that can handle multiple types for a programmer’s profile:

class Programmer<T> {

private languageName: string;
private languageInfo: T;
constructor(lang: string) {
this.languageName = lang;
}
...
}
let programmer1 =
new Programmer<Language.Typescript>("Typescript");
let programmer2 =
new Programmer<Language.Rust>("Rust");

For our Programmer class, T is a type variable for programming language meta data, allowing us to assign various language types to the languageInfo property. Every language will inevitably have different metadata, and therefore need a different type.

A note on type argument inference

In the above example we have used angled brackets with the specific language type when instantiating a new Programmer, with the following syntax pattern:

let myObj = new className<Type>("args");

For instantiating classes, there is not much the compiler can do to guess which language type we want assigned to our programmer; it is compulsory to pass the type here. However, with functions, the compiler can guess which type we want our generics to be — and this is the most common way developers opt to use generics.

To clarify this, let’s refer to our identities() function again. Calling the function like so will assign the string and number types to T and U respectively:

let result = identities<string, number>("argument 1", 100);

However, what is more commonly practiced is for the compiler to pick up on these types automatically, making for cleaner code. We could omit the angled brackets entirely and just write the following statement:

let result = identities("argument 1", 100);

The compiler is smart enough here to pick up on the types of our arguments, and assign them to T and U without the developer needing to explicitly define them.

Caveat: If we had a generic return type that no arguments were typed with, the compiler would need us to explicitly define the types.

When to Use Generics

Generics give us great flexibility for assigning data to items in a type-safe way, but should not be used unless such an abstraction makes sense, that is, when simplifying or minimising code where multiple types can be utilised.

Viable use cases for generics are not far reaching; you will often find a suitable use case in your codebase here and there to save repetition of code — but in general there are two criteria we should meet when deciding whether to use generics:

  1. When your function, interface or class will work with a variety of data types
  2. When your function, interface or class uses that data type in several places

It may well be the case that you will not have a component that warrants using generics early on in a project. But as the project grows, a component’s capabilities often expand. This added extensibility may well eventually adhere to the above two criteria, in which case introducing generics would be a cleaner alternative than to duplicate components just to satisfy a range of data types.

We will explore more use cases where both these criteria are met further down the article. Let’s cover some other features of generics Typescript offer before doing so.

Generic Constraints

Sometimes we may wish to limit the amount of types we accept with each type variable — and as the name suggests — that is exactly what generic constraints do. We can use constraints in a few ways that we will now explore.

Using constraints to ensure type properties exist

Sometimes a generic type will require that certain properties exists on that type. Not only this, the compiler will not be aware that particular properties exist unless we explicitly define them to type variables.

A good example of this is when working with strings or arrays where we assume the .length property is available to use. Let’s take our identity() function again and try to log the length of the argument:

// this will cause an errorfunction identity<T>(arg: T): T {
console.log(arg.length);
return arg;
}

In this scenario the compiler will not know that T indeed has a .length property, especially given any type can be assigned to T. What we need to do is extend our type variable to an interface that houses our required properties. That looks something like this:

interface Length {
length: number;
}

function identity<T extends Length>(arg: T): T {
// length property can now be called
console.log(arg.length);
return arg;
}

T is constrained using the extends keyword followed by the type we are extending, within the angled brackets. Essentially, we are telling the compiler that we can support any type that implements the properties within Length.

Now the compiler will let us know when we call the function with a type that does not support .length. Not only this, .length is now recognised and usable with types that implement the property.

Note: We can also extend from multiple types by separating our constraints with a comma. E.g. <T extends Length, Type2, Type3>.

Explicitly supporting arrays

There is indeed another solution to the .length property problem if we were explicitly supporting the array type. We could have defined our type variables to be an array, like so:

// length is now recognised by declaring T as a type of arrayfunction identity<T>(arg: T[]): T[] {
console.log(arg.length);
return arg;
}
//orfunction identity<T>(arg: Array<T>): Array<T> {
console.log(arg.length);
return arg;
}

Both the above implementations will work, whereby we let the compiler know that arg and the return type of the function will be an array type.

Using constraints to check an object key exists

A great use case for constraints is validating that a key exists on an object by using another piece of syntax: extends keyof. The following example checks whether a key exists on an object we are passing into our function:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

The first argument is the object we are taking a value from, and the second is the key of that value. The return type describes this relationship with T[K], although this function will also work with no return type defined.

What our generics are doing here is ensuring that the key of our object exists so no runtime errors occur. This is a type-safe solution from simply calling something like let value = obj[key];.

From here the getProperty function is simple to call, as done in the following example to get a property from a typescript_info object:

// the property we will get will be of type Difficultyenum Difficulty {
Easy,
Intermediate,
Hard
}
// defining the object we will get a property fromlet typescript_info = {
name: "Typescript",
superset_of: "Javascript",
difficulty: Difficulty.Intermediate,
}
// calling getProperty to retrieve a value from typescript_infolet superset_of: Difficulty =
getProperty(typescript_info, 'difficulty');

This example also throws in an enum to define the type of the difficulty property we have obtained with getProperty.

More Generic Use Cases

Next up, let’s explore how generics can be used in more integral real-world use cases.

API services

API services are a strong use case for generics, allowing you to wrap your API handlers in one class, and assigning the correct type when fetching results from various endpoints.

Take a getRecord() method for example — the class is not aware of what type of record we are fetching from our API service, nor is it aware of what data we will be querying. To rectify this, we can introduce generics to getRecord() as placeholders for the return type and the type of our query:

class APIService extends API {   public getRecord<T, U> (endpoint: string, params: T[]): U {}   public getRecords<T, U> (endpoint: string, params: T[]): U[] {}  ...
}

Our generic method can now accept any type of params, that will be used to query the API endpoint. U is our return type.

Manipulating arrays

Generics allow us to manipulate typed arrays. We may want to add or remove items from an employee database, such as in the following example that utilises a generic variable for the Department class and add() method:

class Department<T> {

//different types of employees
private employees:Array<T> = new Array<T>();

public add(employee: T): void {
this.employees.push(employee);
}
...
}

The above class allows us to manage employees by department, allowing each department and employees within to be defined by one specific type.

Or perhaps you require a more generic utility function to convert an array to a comma separated string:

function arrayAsString<T>(names:T[]): string { 
return names.join(", ");
}

Generics will allow these types of utility functions to become type safe, avoiding the any type in the process.

Extending with classes

We have seen generic constraints being used with React class components to constrain props and state, but they can also be used to ensure that class properties are formatted correctly. Take the following example, that ensures both a first and last name of a Programmer are defined when a function requires them:

class Programmer {

// automatic constructor parameter assignment
constructor(public fname: string, public lname: string) {
}
}

function logProgrammer<T extends Programmer>(prog: T): void {
console.log(`${ prog.fname} ${prog.lname}` );
}
const programmer = new Programmer("Ross", "Bulat");
logProgrammer(programmer); // > Ross Bulat

Note: The constructor here uses automatic constructor parameter assignment, a feature of Typescript that assigns class properties directly from constructor arguments.

This setup adds reliability and integrity to your objects. If your Programmer objects are to be utilised with an API request where you require particular fields to be accounted for, generic constraints will ensure that all are present at compile time.

In Summary

To read more on generics, the official Typescript docs have an up to date reference on them to refer to, and cover more advanced generic use cases.

I have also published an article outlining how generics can be used in a real-world use case, in implementing an API service manager. Apply the knowledge of this article to this practical use case, with the project also available on Github:

I have also documented a Typescript live chat solution, a two part series that outlines a Typescript Express server setup as well as a Typescript based React client. This project is also available on Github:


This has been a brief tour on generics in Typescript with the goal to give clarity about what they are and how they can be used. I hope by now you have some ideas on how to implement them in some ways within your projects.

Criteria before implementation

Generics can be useful in the right circumstances to further abstract and minimise your code. Refer to the two criteria mentioned above before implementing generics — sometimes it is best to leave out the additional complexity where it is not warranted.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade