TypeScript Generics Through Practical Examples
1. Overview
Generics is a tool that helps you write reusable code while maintaining type safety. Instead of writing UserRepository, OrderRepository, ProductRepository over and over, you write a single Repository<T> that works for all. This article walks through practical examples: generic functions, generic classes, constraints, multiple type parameters, and how to combine them with utility types. After reading, you’ll feel confident applying generics to service layers, repositories, and API responses in your TypeScript projects.
2. What are generics and why do you need them
Generics allow you to “parameterize” a type, much like a function takes parameters but at the type level.
// Without generics: have to rewrite for each type
function firstString(arr: string[]): string {
return arr[0];
}
function firstNumber(arr: number[]): number {
return arr[0];
}
// With generics: one function for any type
function first<T>(arr: T[]): T {
return arr[0];
}
const name = first(["Alice", "Bob"]); // T = string, inferred automatically
const age = first([30, 25]); // T = number
<T> is a type parameter — a variable representing the actual type passed at call time. TypeScript infers T automatically based on the argument; you don’t need to specify it manually.
Note:
T,U,K,Vare just naming conventions with no special meaning. In larger codebases, more descriptive names likeTItem,TResponsewill be much easier to read.
3. Generic functions in practice: the groupBy function
A common example when processing data: grouping objects by a field.
interface Order {
id: number;
status: "pending" | "paid" | "cancelled";
customerId: number;
}
function groupBy<T, K extends string | number>(
items: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
const orders: Order[] = [
{ id: 1, status: "paid", customerId: 10 },
{ id: 2, status: "pending", customerId: 11 },
{ id: 3, status: "paid", customerId: 10 },
];
const byStatus = groupBy(orders, (o) => o.status);
Tis the type of items in the array (Order).K extends string | numberrestricts the key to onlystringornumber— because object keys cannot be any other type.- The result
byStatushas typeRecord<"pending" | "paid" | "cancelled", Order[]>, so TypeScript knows exactly which keys can appear.
byStatus.paid -> Order[] (2 items)
byStatus.pending -> Order[] (1 item)
byStatus.cancelled -> Order[] (could be undefined in strict mode)
4. Generic constraints with extends
A constraint limits T to satisfy a certain shape, allowing you to safely access properties inside the generic function.
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
interface Customer extends HasId {
name: string;
email: string;
}
const customers: Customer[] = [
{ id: 1, name: "An", email: "an@example.com" },
{ id: 2, name: "Bình", email: "binh@example.com" },
];
const found = findById(customers, 1); // Customer | undefined
Without extends HasId, the line item.id would error because TypeScript wouldn’t know whether T has an id property.
error TS2339: Property 'id' does not exist on type 'T'.
This is the most common mistake when learning generics: forgetting the constraint and then accessing arbitrary properties on T.
5. Generic class: the Repository<T> example
This is the classic use case — a generic repository for multiple entities.
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
deleteById(id: number): boolean {
return this.items.delete(id);
}
}
interface Product extends Entity {
name: string;
price: number;
}
const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Keyboard", price: 500_000 });
productRepo.save({ id: 2, name: "Mouse", price: 200_000 });
const all = productRepo.findAll(); // Product[]
Repository<T extends Entity>ensures every managed entity has anid, soMap<number, T>safely usesidas the key.- When you instantiate
new Repository<Product>(), every method return type is exactlyProduct— the IDE autocompletesname,priceinstantly.
Note: If you use
Repositoryfor only one entity across your entire project, generics would be over-engineering. Write a specific class first; only generify when you have ≥ 2 places sharing the same logic.
6. Multiple type parameters: the ApiResponse<TData, TError> example
When you need to parameterize more than one type, use multiple type parameters at once.
interface ApiResponse<TData, TError = string> {
success: boolean;
data?: TData;
error?: TError;
}
function fetchSuccess<T>(data: T): ApiResponse<T> {
return { success: true, data };
}
function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
return { success: false, error };
}
interface User {
id: number;
username: string;
}
const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });
const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
code: 404,
message: "User not found",
});
TError = string in the interface declaration is a default generic type — if you don’t provide the second type argument, TypeScript automatically uses string. That’s why ApiResponse<User> is valid even with just 1 type argument.
7. Combining generics with utility types
Utility types like Partial, Pick, Omit are themselves generic types built into TypeScript. Combining them with your own generics is very effective for update functions.
interface Customer {
id: number;
name: string;
email: string;
vip: boolean;
}
function updateEntity<T extends { id: number }>(
original: T,
patch: Partial<Omit<T, "id">>
): T {
return { ...original, ...patch };
}
const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };
const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
Partial<Omit<T, "id">>means: take all fields ofTexceptid, then make all those fields optional.- This way,
updateEntityprevents modifyingid, and the caller only needs to pass fields they want to change — very similar to the PATCH pattern in REST APIs.
If you try to pass { id: 2 } in the patch parameter, TypeScript will error immediately because id was excluded by Omit:
error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.
8. Generics with conditional types: when you need infer
A more advanced step: automatically extract the return type of a function, avoiding manual duplication.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
async function fetchCustomer(id: number): Promise<Customer> {
return { id, name: "An", email: "an@a.com", vip: false };
}
type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, not Promise<Customer>
infer Utells TypeScript to “guess” the type insidePromise<...>and assign it toU.- You don’t need to know beforehand what
fetchCustomerreturns — the type is inferred automatically, and if thefetchCustomersignature changes,FetchResultupdates on its own.
Note: Conditional types with
inferare very powerful but can make code hard to read. Only use them when you need a reusable type utility (likeUnwrapPromise) across many places, not for one-off logic.
9. Writing tests for generic functions
Generics affect type-checking at compile time, but behavior at runtime still needs testing like any other code. Install Vitest:
<!-- package.json (dependencies section, not pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
}
interface Product extends Entity {
name: string;
}
describe("Repository<Product>", () => {
it("saves and retrieves the correct product by id", () => {
const repo = new Repository<Product>();
repo.save({ id: 1, name: "Keyboard" });
const found = repo.findById(1);
expect(found).toBeDefined();
expect(found?.name).toBe("Keyboard");
});
it("returns undefined when id does not exist", () => {
const repo = new Repository<Product>();
const found = repo.findById(999);
expect(found).toBeUndefined();
});
});
Run npx vitest run, and you should see:
✓ Repository<Product> > saves and retrieves the correct product by id
✓ Repository<Product> > returns undefined when id does not exist
Test Files 1 passed (1)
Tests 2 passed (2)
This test doesn’t check types (the TypeScript compiler already did that at build time); it checks the runtime logic of the generic class — ensuring the Map works correctly regardless of what T is.
10. Common mistakes and when not to use generics
- Over-generifying: creating
Repository<T>but your entire project has only one entity. In this case, a specificCustomerRepositoryclass is easier to read and maintain. - Forgetting constraints: writing
function getProp<T>(obj: T, key: string)then accessingobj[key]will error with typeanyor index signature issues. Use<T, K extends keyof T>(obj: T, key: K): T[K]instead. - Using
anyinstead of generics:function identity(x: any): anycompletely loses type safety — TypeScript won’t report any errors. Always prefer<T>(x: T): T. - Too many type parameters: if a function has 4–5 type parameters, the code becomes very hard to read. Consider consolidating related types into a single config interface.
// Hard to read
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }
// Easier to read
interface MergeInput<TBase, TExtra> {
base: TBase;
extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }
Note: If a type parameter is used only once in the entire function signature, you probably don’t need a generic — a concrete type or
unknownis sufficient.
11. Conclusion
Generics help you write reusable code without sacrificing type safety: one Repository<T>, one ApiResponse<TData, TError> in place of dozens of duplicate classes and interfaces. The key is mastering three things — basic type parameters, constraints with extends, and combining with built-in utility types (Partial, Omit, Pick). Don’t genericize everything from the start; extract a generic only when you see logic repeating in at least two different places in your codebase.