Understanding TypeScript Generics: Why, How, and When to Use Them
As you dive deeper into TypeScript (TS) and become more accustomed to the basics of it, youβll start to encounter more advanced features like generics. Generics are a very powerful feature with a lot of potential, but they also have a steep learning curve. In this post, weβll cover all you need to know about generics, their benefits, and how to get started with them.
Prerequisites
There arenβt many prerequisites for learning generics, but I would highly recommend having a good grasp on the fundamentals of TypeScript. Generics can have a steep learning curve, and this will only be made steeper without a basic understanding of TypeScript.
If you would like to follow along with the code snippets and examples included in this post, then you can install TypeScript on your local machine and use a temporary project to experiment. Alternatively, a much easier way to follow along is to use the TypeScript playground, which requires no setup to use.
What are generics in TypeScript?
Generics are a TypeScript feature that allows us to pass in various types of data and create reusable code to handle different inputs. They allow us to define placeholder types which are then replaced when the code is executed with the actual types passed in.
Generics are like a template that can be reused across the same piece of code multiple times but with the value being independent of each invocation of the function. Letβs look at an example to get a better understanding of this.
// π We define a generic value called T with <T>
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
const numberArray: number[] = [1, 2, 3, 4, 5];
const stringArray: string[] = ['apple', 'banana', 'orange'];
// π Note the generic values being passed in <number> & <string>
const firstNumber = getFirstElement<number>(numberArray);
const firstString = getFirstElement<string>(stringArray);
In this example, we define a function to retrieve the first value of a given array, but we donβt want to define the array type in the function as a fixed type like a number or string. So, instead, we define a generic (T
), which is used to type the argument being passed in as well as the return value. This generic value will be replaced at runtime with a more accurate type which you can see when we call the function and pass in the values <number>
and <string>
.
Benefits of using TypeScript generics
Generics offer us multiple benefits as developers, but some of the biggest ones are.
- Type Safety and Error Detection: Generics allow us to write code that operates on various types of data without losing the type safety of TypeScript.
- Code Reusability & Flexibility: In a similar sense to the above point, generics help us make our code more reusable and flexible. This is because, with generics, we can abstract a piece of code to be more generic and work with multiple data types instead of just one, cutting down on the amount of duplicate code required to work with different data types.
- Better Maintainability: Because generics allow us to write more reusable code, we now have fewer instances of code to maintain and update with bug fixes when required.
Using generics with interfaces
We can use generics with interfaces to define custom types for the properties of the object the interface describes. Hereβs a basic example of using generics with an interface.
interface MetaData {
sex: string;
height: "tall" | "short";
favouriteNumber: number;
}
// π Defining our generic
interface Person<T> {
id: number;
name: string;
age: number;
metadata: T;
}
// π Using our generic
const personOne: Person<(number|string)[]> = {
id: 1,
name: 'Jeff',
age: 31,
metadata: ['male', 'tall', 22]
}
// π Using our generic
const personTwo: Person<MetaData> = {
id: 1,
name: 'Jeff',
age: 31,
metadata: {
sex: 'female',
height: 'tall',
favouriteNumber: 45,
}
}
In this example, we define an interface using a generic (Person
), the generic type passed to this interface is then used to type the metadata
property on it. We then have two examples, one where the metadata
property is typed using an array of strings/numbers and another where itβs typed using a second interface called MetaData
.
Both of these are valid examples because the data passed to the metadata property in each object aligns with the generic value being passed into the interface describing the object.
Using generics with types
Using generics with types is pretty similar to using interfaces and follows the same principles we covered above, but this time using the type syntax like so.
type MetaData = {
sex: string;
height: "tall" | "short";
favouriteNumber: number;
}
type Person<T> = {
id: number;
name: string;
age: number;
metadata: T;
}
const personOne: Person<(number|string)[]> = {
id: 1,
name: 'Jeff',
age: 31,
metadata: ['male', 'tall', 22]
}
const personTwo: Person<MetaData> = {
id: 2,
name: 'Jess',
age: 28,
metadata: {
sex: 'female',
height: 'tall',
favouriteNumber: 45,
}
}
First time here? Discover what Prismic can do!
π Meet Prismic, your solution for creating performant websites! Developers, build with your preferred tech stack and deliver a visual page builder to marketers so they can quickly create on-brand pages independently!
Using generics with classes
Much like we have done with interfaces and types, we can pass generics into a class to type methods and return values on the class. Here is an example of just that.
// π Defining our new class with a generic
class ExampleClass<T> {
// π Initializing a new property with an array of the generic value
private values: T[] = [];
// π Setting the argument for this method to be the generic
setValue(value: T): void {
this.values.push(value);
}
// π Setting the return value of this method to be an array of the generic
getValues(): T[] {
return this.values;
}
}
// π Using 'number' as the type to replace our generic 'T'
const example = new ExampleClass<number>();
// π We can now only pass in numbers. Any other type will error.
example.setValue(24);
example.setValue(42);
const values = example.getValues();
console.log(values); // [24, 42]
In this example, we define a new class that takes in a generic and uses that generic to type the property and methods that exist on it. We then initialize a new instance of that class with the type of number and successfully use the methods on it by passing in number values. However, if we were to pass in a string (or another non-number type) to this instance of the class, it would error as weβve already told the class that the generic is a number.
Using generics with functions
Functions are one of the most common ways youβll use generics in TypeScript. Letβs look at some examples of how we can use them.
interface ObjOne {
prop1: string;
prop2: number;
}
interface ObjTwo {
prop3: string;
prop4: string;
}
// Defining our function with a generic type (T)
async function fetchData<T>() {
const response = await fetch('API_URL');
// π Telling TS that the response data is the type of T (our generic)
const data = await response.json() as T;
return data;
}
await fetchData<ObjOne>(); // The returned data would have a type of ObjOne
await fetchData<ObjTwo>(); // The returned data would have a type of ObjTwo
Here we define a new function that performs a fetch request to an API and then parses the resulting JSON using response.json()
. The issue is TypeScript doesnβt know what type that JSON will be, so will default to using a type like any
or unknown
, but this isnβt very helpful for us. Luckily we can tell TypeScript what type the JSON will be by using a generic to type it for us via the as
keyword.
Passing multiple generics
Youβll likely often encounter multiple generics when writing more complex functions being reused in multiple places with varying inputs. To define an extra generic, you can use a comma followed by the next generic placeholder.
// 2 Generics being defined (T & K)
function example<T, K>() {}
// Calling the function and passing types for the generics
example<string, number>()
Here is a complete example of combining two arrays that might have different types using multiple generics.
// Generic function with multiple generics
function mergeArrays<T, K>(arr1: T[], arr2: K[]): (T | K)[] {
return [...arr1, ...arr2];
}
const numArray = [1, 2, 3];
const strArray = ["hello", "world"];
// π You can explicity define the generics like this.
const mergedArray = mergeArrays<number, string>(numArray, strArray);
// π Or, you can let TS infer the types like this but will work.
const mergedArray2 = mergeArrays(numArray, strArray);
console.log(mergedArray); // Output: [1, 2, 3, "hello", "world"]
Passing default types to generics
TypeScript also allows us to pass a default type to our generic. Default types allow generics to be optionally invoked without passing a type parameter, providing more flexibility in code reuse. For example:
type Person<Name extends string = string> = {
name: Name // defaults to `string` if Name isn't provided
age: number
}
type Alison = Person<"Alison"> // => Alison["name"] is "Alison"
type Someone = Person // => Someone["name"] is `string`
By doing this, TypeScript wonβt expect a type to be provided to that generic all of the time and will allow you to omit it from your code when needed. Something to also consider is that you often wonβt need to explicitly define a default type for a generic because TypeScript will infer its type from the value being passed into the generic when used with functions and classes.
Creating mapped types with generics
Mapped types are a feature of TypeScript that allows us to transform and/or manipulate the properties of an object type to return a different type. We can combine these with generics to create more powerful and flexible types for us to use, for example:
// 1οΈβ£ Original Type
type User = {
id: number;
name: string;
age: number;
}
// 2οΈβ£ Mapped type using generics
type PartialUser<T> = {
[K in keyof T]?: T[K]
}
// 3οΈβ£ Using our mapped type, valid syntax β
const partialUser: PartialUser<User> = {
age: 22,
}
// 3οΈβ£ Using a normal type, invalid syntax β
// Error: Type '{ age: number; }' is missing the following properties from type 'User': id, name
const partialUser2: User = {
age: 22,
}
In this example, we create a new mapped type (PartialUser
), allowing us to create partial versions of the type passed into it. So, instead of needing to define every property on the object, we can only define the ones we need to. This can be seen in the two examples above, where the one using the mapped type is valid, and the example using the normal type is invalid.
This works because, in our mapped type, we take the generic type passed in (User
) and loop over each key in the type ([K in keyof T]
). Then we return a new version of that type where all the properties are optional (?: T[K]
).
Creating conditional types with generics
Conditional types allow us to conditionally choose the right type based on a condition we define; we can take this one step further, however, and pair them with generics to allow us to write more reusable code. For instance, here is a basic example of using a conditional type with a generic.
interface Item {
id: number;
name: string;
}
type CheckType<T> = T extends Item ? Item : never;
const result2: CheckType<Item> = {
id: 0,
name: 'Jess'
};
// β Error: Type 'string' is not assignable to type 'never'.
const result3: CheckType<string> = 'hello world';
Here we define a conditional type (CheckType
) that takes in a generic and checks if it extends the Item
interface. If it does, then we know itβs an Item
type and return that. Otherwise, we return the type of never
, which would throw an error as the type being passed into the generic cannot be assigned to never
. This helps protect our code against types of data that shouldnβt be passed in.
Implementing generic constraints
Generic constraints are a method we can use to restrict the types that can be used as generic arguments; this helps us enforce specific rules on the types being passed into a function, for example. Here is an example of doing that.
function logName<T extends {name: string }>(obj: T) {
console.log(obj.name)
}
logName({name: 'Coner'}) // 'Coner'
logName({name: 'Josh', age: 2}) // 'Josh'
logName({ age: 2}) // β Error: Object literal may only specify known properties, and 'age' does not exist in type '{ name: string; }'
For this example, we define a function that logs out the name
property of an object. But, to check the object passed in does have the name
property, we extend the generic using <T extends {name: string }>
. This means any object being passed in will be checked for the name
property, and an error will be thrown if it doesnβt exist. This effectively limits the objects that can be passed into the function to only the ones that satisfy this constraint.
FAQs about TypeScript Generics
Closing thoughts on TypeScript generics
Generics can be a really helpful tool when working with TypeScript and can help us write more reusable, flexible, and easier-to-maintain code. However, they do have an initial barrier to entry that might be difficult to overcome, but once you do, they open up a wide variety of possibilities and benefits in your code.
If youβd like to learn more about TypeScript generics, then make sure to check out the docs.