TypeScript Generals: A Practical Walkthrough With Real Code
If you’ve been writing TypeScript for a while, you’ve probably hit a wall where you want a function or class to work with multiple types without sacrificing type safety. That’s exactly where generics come in. This guide breaks them down from the ground up with practical, copy-paste-ready examples.
Prerequisites
Before diving in, you should have:
- Node.js 20+ installed on your machine
- TypeScript 5.4+ (we reference the latest compiler features)
- A basic understanding of TypeScript fundamentals: interfaces, union types, and basic functions
- Familiarity with ES6+ JavaScript features like arrow functions and destructuring
You can set up a sandbox project quickly:
mkdir generics-practice && cd generics-practice
npm init -y
npm install -D typescript@5.4.5 ts-node@10.9.2
npx tsc --init --strict
The --strict flag matters here because it enables noImplicitAny, which forces you to handle generics explicitly — perfect for learning.
Why Generics Exist
Let’s start with a problem. Suppose you want a function that returns whatever you pass into it:
function identity(value: any): any {
return value;
}
const result = identity("hello");
// result is typed as `any` — you've lost all type information
This works, but it throws away the type information. The compiler can’t tell you that result.toUpperCase() is safe. Generics fix that by letting you define a type variable:
function identity<T>(value: T): T {
return value;
}
const text = identity("hello"); // T is inferred as string
const count = identity(42); // T is inferred as number
// Now the compiler knows:
console.log(text.toUpperCase()); // ✅ Valid
console.log(text.toFixed(2)); // ❌ Error: Property 'toFixed' does not exist on type 'string'
The <T> is a type parameter. Think of it like a placeholder that gets filled in when the function is called.
Generic Functions in Practice
Basic Syntax
Here’s the general pattern:
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = firstElement([1, 2, 3]); // number | undefined
const names = firstElement(["Ada", "Grace"]); // string | undefined
Multiple Type Parameters
Functions can accept multiple generics:
function pair<K, V>(key: K, value: V): { key: K; value: V } {
return { key, value };
}
const entry = pair("id", 1007);
// { key: string; value: number }
Generic Arrow Functions
When writing arrow functions, you need a small workaround in .tsx files (like React) because <T> looks like JSX. Use a trailing comma:
const wrap = <T,>(value: T): T[] => [value];
// In regular .ts files, this works fine too:
const wrapSafe = <T>(value: T): T[] => [value];
Generic Interfaces and Type Aliases
Generics aren’t limited to functions. They shine in data structures.
Generic Interfaces
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
type User = {
id: number;
email: string;
};
const response: ApiResponse<User> = {
data: { id: 1, email: "ada@example.com" },
status: 200,
message: "OK",
timestamp: new Date(),
};
Generic Type Aliases
type PaginatedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
// Usage with a product catalog
type Product = { sku: string; price: number };
const products: PaginatedResult<Product> = {
items: [
{ sku: "WIDGET-001", price: 19.99 },
{ sku: "WIDGET-002", price: 29.99 },
],
total: 142,
page: 1,
pageSize: 20,
};
Generic Classes
Classes use generics to create reusable, type-safe structures. A classic example is a typed event emitter:
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return [...this.items];
}
find(predicate: (item: T) => boolean): T | undefined {
return this.items.find(predicate);
}
remove(predicate: (item: T) => boolean): void {
this.items = this.items.filter((item) => !predicate(item));
}
}
// Instantiate with a specific type
const userStore = new DataStore<{ id: number; name: string }>();
userStore.add({ id: 1, name: "Ada Lovelace" });
userStore.add({ id: 2, name: "Grace Hopper" });
const found = userStore.find((u) => u.name === "Ada Lovelace");
console.log(found); // { id: 1, name: 'Ada Lovelace' }
The type parameter T is available throughout the class — in properties, methods, and return types.
Constraints With extends
Left unchecked, generics accept anything. Sometimes you need to restrict what a type parameter can be. That’s where extends comes in.
Constraining to a Shape
interface HasId {
id: number;
}
function getById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
type Article = HasId & { title: string; body: string };
const articles: Article[] = [
{ id: 1, title: "Generics 101", body: "..." },
{ id: 2, title: "Advanced Types", body: "..." },
];
const article = getById(articles, 1);
// ^? Article | undefined
The constraint ensures T always has an id property, so the function can safely access it.
The keyof Operator
A common pattern combines extends with keyof to create type-safe property accessors:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Ada", age: 36, role: "engineer" };
const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
// TypeScript catches typos at compile time:
const invalid = getProperty(person, "email"); // ❌ Error: Argument of type '"email"' is not assignable to parameter of type '"name" | "age" | "role"'
Default Type Parameters
You can provide default types for generic parameters, similar to default arguments in functions:
interface requestOptions {
retries: number;
}
function createFetcher<T = unknown, O extends requestOptions = requestOptions>(
transform: (raw: unknown) => T
) {
return async (url: string): Promise<T> => {
const res = await fetch(url);
const raw = await res.json();
return transform(raw);
};
}
// Explicit type parameter
const fetchUser = createFetcher<{ name: string }>((raw) => raw as { name: string });
// Default kicks in (T becomes unknown)
const fetchRaw = createFetcher((raw) => raw);
Defaults are especially useful in library code where most users want a sensible default but power users need customization.
Conditional Types
This is where generics start feeling like metaprogramming. A conditional type selects one of two types based on a condition:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
A more practical example — unwrapping types:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type Resolved = Unwrap<Promise<number>>; // number
type Plain = Unwrap<string>; // string
The infer keyword declares a new type variable within a conditional — it captures whatever type is in that position.
Practical Conditional Type: Deep Readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Config = {
api: {
baseUrl: string;
timeout: number;
};
features: string[];
};
type FrozenConfig = DeepReadonly<Config>;
// Everything is deeply readonly — useful for immutability
Built-in Utility Types That Use Generics
TypeScript ships with several utility types built on generics. Here are the ones you’ll use most:
// Partial — makes all properties optional
type PartialUser = Partial<User>;
// Equivalent to: { id?: number; email?: string }
// Pick — select specific properties
type UserEmail = Pick<User, "email">;
// Equivalent to: { email: string }
// Omit — remove specific properties
type UserWithoutId = Omit<User, "id">;
// Equivalent to: { email: string }
// Record — a typed map/dictionary
type UserMap = Record<number, User>;
// Keys are numbers, values are Users
// ReturnType — extract the return type of a function
function getConfig() {
return { port: 3000, host: "localhost" };
}
type Config = ReturnType<typeof getConfig>;
// { port: number; host: string }
Understanding these deeply means you can compose them:
type UpdateUserInput = Partial<Pick<User, "email">>;
// { email?: string }
Common Pitfalls and How to Avoid Them
Pitfall 1: Overusing any Inside Generic Functions
The mistake:
function parse<T>(json: string): T {
return JSON.parse(json); // Return type is `any`, cast to T silently
}
This looks type-safe but isn’t. JSON.parse returns any, and the function signature claims it returns T. The caller gets no real safety.
The fix: Add a runtime validation layer or use a library like zod:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
function parseUser(json: string): User {
return UserSchema.parse(JSON.parse(json));
}
Pitfall 2: Generic Type Parameters You Don’t Use
The mistake:
function log<T>(message: string): void {
console.log(message);
// T is declared but never used
}
TypeScript 5.4 flags this in some configurations. If you don’t use the type parameter in the function body or signature, remove it.
Pitfall 3: Assuming Generics Validate at Runtime
Generics are compile-time only. They don’t exist after transpilation. This means:
function isString<T>(value: T): boolean {
return typeof value === "string"; // This works, but not because of T
}
If you need runtime type checking, you have to implement it explicitly:
function assertString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
Pitfall 4: Forgetting That Generic Inference Can Surprise You
function combine<T>(a: T[], b: T[]): T[] {
return [...a, ...b];
}
const result = combine([1, 2, 3], ["four"]);
// No error! T is inferred as `string | number`
The compiler widens T to accommodate both arrays. If you want strict matching, add an explicit type argument:
const strict = combine<number>([1, 2, 3], [4, 5]); // ✅
const error = combine<number>([1, 2, 3], ["four"]); // ❌ Type 'string' is not assignable to type 'number'
Real-World Use Cases
1. A Type-Safe Event Bus
type EventHandler<T = unknown> = (payload: T) => void;
class EventBus<EventMap extends Record<string, unknown>> {
private handlers: { [K in keyof EventMap]?: EventHandler<EventMap[K]>[] } = {};
on<K extends keyof EventMap>(event: K, handler: EventHandler<EventMap[K]>): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
this.handlers[event]?.forEach((handler) => handler(payload));
}
}
// Define your application's events
interface AppEvents {
userLoggedIn: { userId: number; timestamp: Date };
purchaseCompleted: { orderId: string; total: number };
errorOccurred: { message: string; code: number };
}
const bus = new EventBus<AppEvents>();
bus.on("userLoggedIn", ({ userId, timestamp }) => {
console.log(`User ${userId} logged in at ${timestamp.toISOString()}`);
});
bus.emit("userLoggedIn", { userId: 42, timestamp: new Date() });
// These are compile-time errors:
bus.emit("userLoggedIn", { userId: "42" }); // ❌ Type 'string' is not assignable to type 'number'
bus.on("unknownEvent", () => {}); // ❌ Argument of type '"unknownEvent"' is not assignable...
2. A Generic Repository Pattern
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
class InMemoryRepository<T extends { id: string }> implements Repository<T> {
private store = new Map<string, T>();
async findById(id: string): Promise<T | null> {
return this.store.get(id) ?? null;
}
async findAll(): Promise<T[]> {
return Array.from(this.store.values());
}
async save(entity: T): Promise<T> {
this.store.set(entity.id, entity);
return entity;
}
async delete(id: string): Promise<void> {
this.store.delete(id);
}
}
// Usage
type Task = { id: string; title: string; done: boolean };
const taskRepo = new InMemoryRepository<Task>();
await taskRepo.save({ id: "task-1", title: "Write article", done: false });
const allTasks = await taskRepo.findAll();
3. Type-Safe API Client
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface Endpoint<TParams extends unknown[], TResponse> {
method: HttpMethod;
path: (...params: TParams) => string;
parse: (raw: unknown) => TResponse;
}
function request<TParams extends unknown[], TResponse>(
endpoint: Endpoint<TParams, TResponse>,
...params: TParams
): Promise<TResponse> {
return fetch(endpoint.path(...params), { method: endpoint.method })
.then((res) => res.json())
.then(endpoint.parse);
}
// Define endpoints
const getUser = {
method: "GET" as HttpMethod,
path: (id: number) => `/api/users/${id}`,
parse: (raw: unknown) => raw as { id: number; name: string },
};
// Type-safe calls
const user = await request(getUser, 42);
// ^? { id: number; name: string }
// Compile-time error: wrong parameter type
const bad = await request(getUser, "42"); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'
Advanced Pattern: Mapped Types
Generics combine with mapped types to transform object shapes programmatically:
type Stringify<T> = {
[K in keyof T]: string;
};
type Point = { x: number; y: number };
type StringPoint = Stringify<Point>;
// { x: string; y: string }
// Make all methods optional
type OptionalMethods<T> = {
[K in keyof T]?: T[K];
};
// Add a prefix to all keys
type Prefix<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
type PrefixedUser = Prefix<{ name: string; email: string }, "user">;
// { userName: string; userEmail: string }
These patterns are the backbone of many popular libraries — zod, typebox, and ORM query builders all rely on them.
Performance and Compilation Considerations
Deeply nested generic types can slow down the TypeScript compiler. If you notice your build times creeping up:
- Avoid recursive types beyond a reasonable depth —
DeepReadonlyover a 10-level nested object can be expensive. - Use simpler type aliases for internal intermediate types.
- Profile with
tsc --extendedDiagnosticsto identify bottlenecks:
npx tsc --noEmit --extendedDiagnostics
The output shows time spent in type checking and the number of types instantiated.
Key Takeaways
- Generics are compile-time only. They vanish after transpilation — design for compile-time safety, not runtime behavior.
- Start simple. A basic
<T>parameter covers most use cases. Reach for constraints and conditional types