Introduction to TypeScript Generics
Generics provide a way to create reusable components in TypeScript. They allow you to write flexible, type-safe code that can work with different types while maintaining full type information.
Understanding Generics with Arrays
In TypeScript, you can define an Array<> and specify the type within the angular brackets. The type here is the generic part:
type myArray = Array<number>
You can put any type within the brackets, which changes the type of myArray. In this example, the type of myArray is an array of numbers.
The Problem: Type Constraints
Consider this function that returns the last element of an array:
const array = (arr: Array<number>) => {
return arr[arr.length - 1]
}
const a1 = array([1, 2, 3])
const a2 = array(['a', 'b', 'c'])
Here, a1 works flawlessly, but a2 will give an error because we passed an array of strings as a parameter. One way to solve this is changing the type of arr from Array<number> to Array<any>, but this way we lose type definitions and type safety.
The Solution: Generic Functions
Here's a better approach using generics:
const array = <T>(arr: Array<T>) => {
return arr[arr.length - 1]
}
T is the generic type that can be passed to the function. T is a variable that can be changed according to our needs. According to the above code snippet, we can take a generic array—the type of arr is not known ahead of time.
Explicitly Typing Return Values
A generic type can be explicitly declared as the return type from the function:
const array = <T>(arr: Array<T>): T => {
return arr[arr.length - 1]
}
Now TypeScript knows that whatever type is in the array, the function returns that same type.
Overwriting Type Inference
Now a1 will have a generic type of number, and a2 will have a type of string. We can also explicitly set the type in each case—this is known as overwriting the inference:
const a2 = array<string>(['a', 'b', 'c'])
Multiple Generic Types
Let's look at an example with multiple generic types:
const array = <X, Y>(x: X, y: Y): [X, Y] => {
return [x, y]
}
const a1 = array(5, 6)
const a2 = array("a", "b")
const a3 = array("a", 7)
We can explicitly set the types in each case:
const a1 = array<number, number>(5, 6)
const a2 = array<string, string>("a", "b")
const a3 = array<string | null, number>("a", 7)
Default Generic Types
You can also set a default type for generics:
const array = <X, Y = number>(x: X, y: Y): [X, Y] => {
return [x, y]
}
const a3 = array<string | null>("a", 7)
Here, if you don't specify Y, it defaults to number.
Extending Generic Types
Sometimes you need to constrain what types can be used with generics. Let's look at an example:
const myFunc = (obj: { firstName: string, lastName: string }) => {
return {
...obj,
fullName: obj.firstName + " " + obj.lastName
}
}
const a4 = myFunc({ firstName: "peter", lastName: "parker" })
This function takes an object as a parameter, keeps all the values in the object, and adds a new property called fullName. It expects the object to have firstName and lastName properties.
The Problem with Additional Properties
What if an object has additional properties?
const a5 = myFunc({
firstName: "kenny",
lastName: "sebastian",
email: "seb@kenny.com"
})
This will show an error because myFunc doesn't have an email parameter defined in its type.
The Solution: Generic Constraints
We can modify myFunc to accept any object as long as it contains at least firstName and lastName properties:
const myFunc = <T extends { firstName: string, lastName: string }>(obj: T) => {
return {
...obj,
fullName: obj.firstName + " " + obj.lastName
}
}
Now myFunc will accept all objects that have at least firstName and lastName properties, while preserving any additional properties and their types.
Generics with Interfaces
You can also use generics with interfaces to create flexible, reusable type definitions:
interface User<T> {
id: string;
email: string;
bio: T;
}
type NumberUser = User<number>
type StringUser = User<string>
This allows you to extend the base interface with consistent id and email properties while allowing the bio property to vary based on your needs. You could have a bio that's a number, a string, or even a complex object—all while maintaining type safety.
Conclusion
TypeScript generics are a powerful feature that allows you to:
- Write reusable code that works with multiple types
- Maintain type safety throughout your application
- Create flexible APIs that adapt to different use cases
- Constrain types to ensure they meet specific requirements
By mastering generics, you can write more maintainable and type-safe TypeScript code that's both flexible and robust.