Mastering TypeScript Generics for Reusable, Type-Safe Code
Are you tired of writing duplicate logic for different data types in TypeScript? Do you find yourself resorting to any just to get a function to accept different inputs, sacrificing type safety in the process?
If so, it’s time to master Generics.
Generics are a fundamental feature of statically-typed languages. They allow developers to pass types as parameters to another type, function, or structure. By making a component generic, you give it the ability to accept and enforce typing that isn’t determined until the component is actually used.
This leads to improved code flexibility, maximum reusability, and the elimination of duplication. In this post, we’ll walk through how to become a more confident developer using TypeScript Generics.
🚀 Complete JavaScript Guide (Beginner + Advanced)
🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)
The Problem: Indeterminate Types
Let’s start with a scenario where TypeScript’s standard typing falls short. Imagine we have a base abstract class Vehicle extended by two concrete classes: Car and Truck.
abstract class Vehicle {
abstract drive(): void;
}
class Car extends Vehicle {
drive() { console.log("Vroom! Driving a car."); }
openTrunk() { console.log("Opening trunk."); }
}
class Truck extends Vehicle {
drive() { console.log("Honk! Driving a truck."); }
loadCargo() { console.log("Loading cargo."); }
}
We also have a generalized factory function designed to create any type of vehicle:
function createVehicle(VehicleConstructor: new () => Vehicle): Vehicle {
return new VehicleConstructor();
}
The problem arises when we use this function. TypeScript only knows that the function returns a Vehicle. It loses the specific type information of the subclass.
const myCar = createVehicle(Car); // Problem: myCar is type 'Vehicle', not 'Car' myCar.drive(); // Works, as it's on the base class myCar.openTrunk(); // Error: Property 'openTrunk' does not exist on type 'Vehicle'.
We want myCar to be typed as a Car and a truck created this way to be typed as a Truck. We can achieve this with generics.
What Are Generics?
In TypeScript, generics are defined using angle brackets: <T>.
Think of generics just like function parameters. In a normal function, you pass parameter values inside parentheses (). With generics, you pass types inside angle brackets <>.
T is a placeholder (often called a type parameter) that gets replaced with a specific type when the function or class is used.
The Basics: Generic Functions
Let’s look at a simple identity function. Without generics, if we wanted it to handle strings, numbers, and objects, we might have to use any, which loses type safety.
With generics, we define the type parameter T, and use it to type the argument and the return value:
function identity<T>(arg: T): T {
return arg;
}
// Usage 1: Explicitly passing the type string
let userIdentity = identity<string>("Alex");
// userIdentity is definitively typed as string
// Usage 2: Passing a custom type
interface CustomerIdentity { email: string; phone: number; }
const customer = { email: "test@test.com", phone: 5551234 };
// userIdentity gets typed as CustomerIdentity
let customerInfo = identity<CustomerIdentity>(customer);
TypeScript is often smart enough to infer the type without you passing it explicitly:
// T is automatically inferred as string literal "Alex"
let inferredIdentity = identity("Alex");
Advanced Features: Constraints and Defaults
Generics become even more powerful when you add rules to them.
1. Generic Constraints (extends)
Sometimes you don’t want T to be just any type; you need it to have a specific shape. You can enforce this using the extends keyword.
If we only want our generic to accept objects, not primitives like strings or numbers, we can constrain it to Record<string, any>:
function saveToDatabase<T extends Record<string, any>>(data: T) {
console.log("Saving", data);
}
saveToDatabase({ id: 1, name: "Alex" }); // OK!
saveToDatabase("Alex"); // Error: Argument of type 'string' is not assignable to constraint.
2. Default Generic Types
Just like function parameters can have default values, generic parameters can have default types. This serves as a fallback if no type parameter is provided.
// If T isn't provided, default to Record<string, any>
interface ApiResponse<T = Record<string, any>> {
data: T;
status: number;
}
// Uses default type
const unknownResponse: ApiResponse = {
data: { someRandomField: "ABC" }, // Type is Record<string, any>
status: 200
};
Solving the Factory Problem
Now that we understand the basics, let’s fix our createVehicle factory function from the beginning of the post.
We need to tell TypeScript that the type returned by the function is the same type as the class constructor we passed in.
// We use T to represent the specific vehicle type.
// We constrain T so it must at least be a Vehicle.
function createVehicle<T extends Vehicle>(VehicleConstructor: new () => T): T {
return new VehicleConstructor();
}
// Now, TypeScript infers the type correctly based on the argument!
const myCar = createVehicle(Car); // myCar is type 'Car'
myCar.openTrunk(); // This now works perfectly!
const myTruck = createVehicle(Truck); // myTruck is type 'Truck'
myTruck.loadCargo(); // Works!
Generics in OOP: The Repository Pattern
Generics shine heavily in Object-Oriented programming, particularly when implementing patterns like repositories. We can define interfaces and classes that handle generic types.
Let’s create a repository that can manage any type of Vehicle, ensuring type safety for adding and retrieving items.
1. The Generic Interface
First, we define an interface for a repository holding items of type T.
interface Repository<T> {
add(item: T): void;
getAll(): T[];
}
2. The Generic Class implementation
Next, we implement this interface in a class. We’ll specify that this repository only deals with types that extend Vehicle.
Note: In the video transcript, the class uses parameter K while the interface used T. This demonstrates that the names are arbitrary placeholders, but they must be consistent within their own scope.
// K must extend Vehicle, and it implements Repository for type K
class VehicleRepository<K extends Vehicle> implements Repository<K> {
private items: K[] = [];
add(item: K): void {
this.items.push(item);
console.log(`Added a vehicle.`);
}
getAll(): K[] {
return this.items;
}
}
3. Usage
Now we can instantiate specific repositories for Cars and Trucks. TypeScript will ensure we don’t mix them up.
// Create a repository specifically for Cars const carRepository = new VehicleRepository<Car>(); carRepository.add(new Car()); // carRepository.add(new Truck()); // Error! Argument of type 'Truck' is not assignable to parameter of type 'Car'. // Create a repository specifically for Trucks const truckRepository = new VehicleRepository<Truck>(); truckRepository.add(new Truck()); // We can safely access specific methods later carRepository.getAll().forEach(car => car.openTrunk()); truckRepository.getAll().forEach(truck => truck.loadCargo());
Conclusion
Generics are a powerful tool for creating flexible, reusable components while maintaining the strict type safety that makes TypeScript so valuable. Whether you are dealing with API responses that return unpredictable shapes, building factory functions, or implementing complex design patterns like Repositories, generics help you write cleaner, more confident code.