How to use generics in typescript
Aug 07, 2024.
Why do we need Generics
Generics in TypeScript allow you to write components that work with any data type while preserving the benefits of type safety. This is a game-changer when building something like an eCommerce platform where you need consistency and flexibility across various entities. With generics, you can create a service once and reuse it across your codebase.
For instance, a service that handles product data can also handle customer data simply by switching the type. Let’s see how generics can help us build this flexibility into our eCommerce application.
Introducing TypeScript Generics
Let’s begin with a simple example to explain generics. Imagine a generic function that returns the argument passed to it. This function can handle any type of data—be it a number, string, or even a complex object.
typescript
function identity<T>(arg: T): T {
return arg;
}
let result1 = identity<number>(5);
let result2 = identity<string>('Hello');
console.log(result1); // Output: 5
console.log(result2); // Output: Hello
Generic Services for an E-Commerce Platform
While this is a simple example, generics are incredibly powerful when applied to more complex scenarios like creating services for an eCommerce platform. For example, instead of writing a separate service to handle products, users, or orders, we can create a generic service that works for any type of data. This reduces code duplication and makes our system more scalable.
typescript
class DataService<T> {
private data: T[] = [];
addItem(item: T): void {
this.data.push(item);
}
getItemById(index: number): T | undefined {
return this.data[index];
}
}
const productService = new DataService<Product>();
productService.addItem({ id: 1, name: 'Laptop', price: 1500 });
console.log(productService.getItemById(0)); // Output: { id: 1, name: 'Laptop', price: 1500 }
const userService = new DataService<User>();
userService.addItem({ id: 1, name: 'John Doe', email: 'john@example.com' });
console.log(userService.getItemById(0)); // Output: { id: 1, name: 'John Doe', email: 'john@example.com' }
Using Generics to Scale
With the `DataService` class, we can now handle products, users, orders, or any other entities without rewriting code for each new data type. We simply pass the appropriate type to the service and let it handle the rest. This is the power of TypeScript generics in action, helping us write reusable, type-safe code that scales as our eCommerce platform grows.
Enforcing Constraints with Generics
Now, let’s take this one step further. What if we want to enforce certain constraints on the generic types? For example, in our eCommerce platform, we may want to ensure that all entities have an `id` field, regardless of whether they are products, users, or orders. We can enforce this using constraints in generics.
typescript
interface Identifiable {
id: number;
}
function logEntity <T extends Identifiable> (entity: T): void {
console.log(entity.id);
}
logEntity({ id: 1, name: 'Laptop' }); // Output: 1
logEntity({ id: 2, email: 'john@example.com' }); // Output: 2
By using constraints like `Identifiable`, we can ensure that only objects with an `id` field are passed into our generic function. This provides additional safety while still allowing us to work with different types.
Generics with arrays
Let’s take a look at how we can use generics with arrays.
typescript
function getFirstItem<T>(items: T[]): T {
return items[0];
}
const productIds = [101, 102, 103];
const productNames = ['Laptop', 'Phone', 'Tablet'];
console.log(getFirstItem(productIds)); // Output: 101
console.log(getFirstItem(productNames)); // Output: Laptop
In this example, the `getFirstItem` function works with any type of array, whether it's an array of numbers, strings, or even objects. This flexibility is one of the many benefits of using generics. Now, let's take it a step further by applying constraints to ensure that our generic types meet certain criteria.
Applying constraints with keyof
Constraints help us limit the types that can be used with our generic functions. In the example, we might want to ensure that our objects have certain properties like `id` and `name`. We can enforce this using TypeScript's `keyof` constraint, ensuring that our generic type only allows certain keys from the object.
typescript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const product = { id: 1, name: 'Laptop', price: 1500 };
console.log(getProperty(product, 'name')); // Output: Laptop
console.log(getProperty(product, 'price')); // Output: 1500
With the `getProperty` function, we are using `keyof` to constrain the keys that can be passed into our function. This ensures that we only access properties that exist on the object, providing extra safety and preventing errors.
Partials, Readonly and Record
Next, let's explore how to leverage some of TypeScript’s built-in generic utility types, such as `Partial`, `Readonly`, and `Record`, to further enhance the flexibility of our code.
typescript
interface Product {
id: number;
name: string;
price: number;
}
function updateProduct(id: number, updates: Partial<Product>): Product {
const product = getProductById(id);
return { ...product, ...updates };
}
const updatedProduct = updateProduct(1, { price: 1200 });
console.log(updatedProduct); // Output: { id: 1, name: 'Laptop', price: 1200 }
In the `updateProduct` function, we use the `Partial` utility type to allow partial updates to a `Product`. Utility types like `Partial`, `Readonly`, and `Record` make it easy to modify how types are used throughout your application, without having to redefine them.
Finally, let’s see how we can combine generics and interfaces to further enhance our eCommerce platform. We’ll define a generic interface and implement a function that uses it.
typescript
interface Entity<T> {
id: number;
data: T;
}
function createEntity<T>(id: number, data: T): Entity<T> {
return { id, data };
}
const productEntity = createEntity(1, { name: 'Laptop', price: 1500 });
console.log(productEntity); // Output: { id: 1, data: { name: 'Laptop', price: 1500 } }
By defining a generic interface `Entity`, we ensure that our `createEntity` function can handle different types of data while maintaining type safety. This technique allows us to build reusable, flexible components that can adapt to any future changes in our application.
In conclusion, TypeScript generics give you the power to write code that is reusable, scalable, and type-safe. By mastering these concepts, you’ll be able to build more robust applications, whether you're working on an eCommerce platform or any other project that requires flexibility across multiple data types.