Typescript Generics Explained
Learn what generics are and how they are used in Typescript
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:
- When your function, interface or class will work with a variety of data types
- 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 error
function 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 array
function identity<T>(arg: T[]): T[] {
console.log(arg.length);
return arg;
}
//or
function 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 Difficulty
enum Difficulty {
Easy,
Intermediate,
Hard
}
// defining the object we will get a property from
let typescript_info = {
name: "Typescript",
superset_of: "Javascript",
difficulty: Difficulty.Intermediate,
}
// calling getProperty to retrieve a value from typescript_info
let 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.
Learn more about generic capabilities with dynamic objects and subsets of objects:
Learn how generics are used with conditional statements within TypeScript:
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: Advanced Typescript by Example: API Service Manager.
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: Typescript Live Chat: Express and Socket.io Server Setup.
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.