In this article, we break down the concepts behind TypeScript generics and explain the benefit of using them to create reusability in our TypeScript code.
TypeScript generics is one piece of TypeScript that often confuses newcomers since it makes TypeScript code appear a lot more complicated than it actually is.
First, it helps to understand what generics are. Generics is a programming method/tool that exists in languages like C# and Java and is geared to help create reusable components that can work with a variety of different types. Generics make this possible by allowing the abstraction of types used in functions or variables. TypeScript adopts this pattern by allowing us to create code that can work with different types.
We’ll go through a simple example to illustrate the basics of generics. Assume we had a function called init()
that received an argument and returned said argument.
const init = (arg) => {
return arg;
};
For those unfamiliar with TypeScript, the highlight behind TypeScript is the capability to specify types for our variables, parameters, functions, etc. in our code. This helps make our JavaScript code more secure by ensuring types are checked during compile-time (i.e., when code is being compiled) as opposed to run-time (i.e., when the code is being run).
If there is ever an issue or mismatch in our types, TypeScript will help notify us before we get to the point of having to run our code.
Since our init()
function above is expected to return what it receives, we can specify the type of the argument and the value returned by the function to be the same. As an example, we can say they would have the type number
.
const init = (arg: number): number => {
return arg;
};
If we were to provide a value that is not a number
to our function, TypeScript will emit an error.
const init = (arg: number): number => {
return arg;
};
init(5); // GOOD
init("5"); // ERROR (Argument of type 'string' is not assignable to parameter...)
If we tried to change the return type of the function, TypeScript will display a warning, and rightly so, since it infers the type of the parameter being returned from the function.
const init = (arg: number): string => {
return arg; // ERROR (Type 'number' is not assignable to type 'string')
};
What if we wanted the init()
function to be reusable for different types? One thing we could try to do is leverage Union Types
where the argument type and returned type could be one of many types.
For example, we could say the function argument can accept a number
or a string
and return either a number
or a string
.
const init = (arg: number | string): number | string => {
return arg;
};
init(5); // GOOD (arg type and return type = number)
init("5"); // GOOD (arg type and return type = string)
Though this would work in certain cases, the example above won’t be reusable in cases we don’t know the type of the argument we’ll pass in.
Another approach we could take that would work for all types is to use the any
type.
const init = (arg: any): any => {
return arg;
};
init(5); // GOOD
init("5"); // GOOD
Using any
would work, but it’s far from ideal since we won’t be able to constrain what arguments the function accepts or infer what the function is to return. This is because any
is a special TypeScript type that
isn’t type-checked so it should only be used sparingly.
Here is where generics and the capability of passing a type variable comes in. Just like how we’ve said init()
can accept an argument, we can also say that init()
can accept a type variable,
or in other words a type parameter or type argument.
In TypeScript, we can pass type variables with the angle brackets syntax (<>
). Here’s an example of having the init()
function accept a type variable denoted with the letter T
.
const init = <T>(arg: any): any => {
return arg;
};
Just like how the value argument is available in the function, the type argument is available in the function as well. We could say that whatever type variable is passed will be the type of the argument and the return type of the function.
const init = <T>(arg: T): T => {
return arg;
};
To reiterate what’s happening above:
init()
function accepts a type variable denoted with the letter T
.arg
parameter: arg: T
.const init = (): T => {}
If we now were to reuse the init()
function for different purposes, we can dictate the type variable in the function for different parameters.
const init = <T>(arg: T): T => {
return arg;
};
init<number>(5); // arg type and return type = number
init<string>("5"); // arg type and return type = string
init<any>({ fresh: "kicks" }); // arg type and return type = any
In the example above, TypeScript will be smart enough to recognize the value of the type variable
T
, without always specifying a type value (e.g.,<any>
). This only works in simple cases. In more complicated cases, we’ll need to ensure type variables are being passed in.
Generics can be used extensively and aren’t only specific to functions. Assume we wanted to have our init()
function create an object that has a field
property with the value of arg
.
const init = <T>(arg: T): T => {
const obj = {
field: arg,
};
return arg;
};
Furthermore, we can assume we wanted to type constrain the obj
created to a particular Interface type. TypeScript type aliases and interfaces also accept type variables.
Interfaces and type aliases allow us to describe the types of our JavaScript objects.
// Type Alias
type InitObj = {};
// Interface
interface InitObj {}
Here’s an example of assigning an interface type to an object that contains a string
property.
interface InitObj {
field: string;
}
const obj: InitObj = {
field: "hello world", // GOOD (field is given a string value)
};
To describe the shape of objects, either a type alias or an interface can be used with minor differences between them. We’ll resort to using an interface since the TypeScript team has historically used interfaces to describe the shape of objects.
We could create an InitObj
interface above the function that sets the type of a field
property to a type variable being passed in.
interface InitObj<T> {
field: T;
}
const init = <T>(arg: T): T => {
const obj = {
field: arg,
};
return arg;
};
In the init()
function, we can define the type of obj
as the InitObj
interface and pass the type variable along. We can also have the function then return the field property from obj
to conform
to the expected return type of the init()
function.
interface InitObj<T> {
field: T;
}
const init = <T>(arg: T): T => {
const obj: InitObj<T> = {
field: arg,
};
return obj.field;
};
TypeScript also allows for the capability to have default generic type values (i.e., generic parameter defaults).
Here’s an example of having our init()
function and InitObj
interface assign a default type value of any
to the type variable that can be passed in.
interface InitObj<T = any> {
field: T;
}
const init = <T = any>(arg: T): T => {
const obj: InitObj<T> = {
field: arg,
};
return obj.field;
};
Now if a type variable isn’t defined when using the init()
function and the compiler isn’t able to infer what the type variable might be, it’ll simply be set to any
.
By convention, the letter T
is often used to infer a type variable, most likely due to the fact it stands for Type. We could very well use any letter we want—U
, V
, etc.
In certain cases, some prefer to extrapolate the type variable name, especially if one might pass in multiple type variables. Here’s an example of the init()
function being able to accept two type variables—TData
and TVariables
.
interface InitObj<T = any> {
field: T;
}
const init = <TData = any, TVariables = any>(arg: TData): TData => {
const obj: InitObj<TData> = {
field: arg,
};
return obj.field;
};
To summarize what we’ve learned, let’s break the above code sample down step by step.
Outside of our function, we create an interface to describe the shape of an object that has a field
property.
T
and given a default value of any
.field
property.interface InitObj<T = any> {
field: T;
}
We declare an init()
function.
TData
and TVariables
. Both are given a default value of any
. Since we weren’t using the TVariables
interface in our function so we’ll
omit it from our function.TData
type variable to be the type of the arg
parameter and the returned value of the function.const init = <TData = any>(arg: TData): TData => {
// ...
};
In our init()
function, we create an object with the name of obj
that has a field
property. The field
property is given a value of the arg
parameter passed into the function.
obj
to be InitObj
. As we specify its type, we pass the TData
as a type variable to the InitObj
.const init = <TData = any>(arg: TData): TData => {
const obj: InitObj<TData> = {
field: arg,
};
};
Lastly, we have our function simply return the value of the obj.field
property.
interface InitObj<T = any> {
field: T;
}
const init = <TData = any>(arg: TData): TData => {
const obj: InitObj<TData> = {
field: arg,
};
return obj.field;
};
init<number>(5); // GOOD - function returns 5
init<string>("5"); // GOOD - function returns "5"
/*
ERROR
Argument of type '{ nestedField: string; }' is not assignable
to parameter of type 'string'.
*/
init<string>({ nestedField: "5" });
// GOOD - function returns { nestedField: "5" }
init<{ nestedField: string }>({ nestedField: "5" });
Generics are a powerful tool in helping make TypeScript code reusable while ensuring type safety remains. For people who are new to TypeScript, the angle brackets syntax can be one of the first things that makes TypeScript code hard to read. We hope this article helps explain, in an easy and understandable manner, how generics can be implemented in TypeScript.
Hassan is a senior frontend engineer and has helped build large production applications at-scale at organizations like Doordash, Instacart and Shopify. Hassan is also a published author and course instructor where he’s helped thousands of students learn in-depth frontend engineering skills like React, Vue, TypeScript, and GraphQL.