
Introduction 🎯
TypeScript isn’t just about adding types to JavaScript. It’s a superpower that catches bugs at compile-time, makes refactoring safe, and documents your code in a way that lives alongside your logic.
This masterclass takes you beyond basic types. We’ll explore advanced patterns, conditional types, generics, utility types, and architectural decisions that separate TypeScript novices from experts. Whether you’re building backends with Express or NestJS, APIs, libraries, or full-stack applications, these techniques will level up your code.
All examples are production-ready and tested. No fluff. Let’s go. 🚀
Part 1: Advanced Type Fundamentals
1.1 Discriminated Unions (The Most Powerful Pattern)
Discriminated unions let you model complex states safely:
// ❌ Weak pattern (prone to errors)
interface ApiResponse {
success: boolean;
data?: any;
error?: any;
}
// ✅ Strong pattern (compiler catches misuse)
type ApiResponse<T> =
| { success: true; data: T; error?: never }
| { success: false; data?: never; error: ErrorDetails };
interface ErrorDetails {
code: string;
message: string;
}
// Type-safe usage
function handleResponse<T>(response: ApiResponse<T>) {
if (response.success) {
// TypeScript knows error is undefined here
console.log("Data:", response.data);
} else {
// TypeScript knows data is undefined here
console.log("Error:", response.error.message);
}
}
Why this matters: TypeScript prevents you from accessing data when success is false. The compiler enforces correct state handling.
1.2 Conditional Types (Type-Level If/Else)
Conditional types let you compute types based on other types:
// Basic conditional
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<number>; // false
// Practical example: Extract promise type
type Unwrap<T> = T extends Promise<infer U> ? U : T;
const promiseData: Promise<{ id: number; name: string }> = Promise.resolve({
id: 1,
name: "Alice",
});
type DataType = Unwrap<typeof promiseData>; // { id: number; name: string }
// Real-world: API response helper
interface ApiEndpoint<T> {
url: string;
method: "GET" | "POST";
body?: T;
}
type ExtractBody<T> = T extends ApiEndpoint<infer B> ? B : never;
const createUserEndpoint: ApiEndpoint<{ email: string; password: string }> = {
url: "/users",
method: "POST",
};
type CreateUserBody = ExtractBody<typeof createUserEndpoint>;
// { email: string; password: string }
1.3 Mapped Types (Transform Types)
Build new types by iterating over properties:
// Basic: Make all properties readonly
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
id: number;
email: string;
name: string;
}
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly email: string; readonly name: string }
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K];
};
// Extract only string properties
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type UserStrings = StringProperties<User>;
// { email: string; name: string }
// Real-world: API DTOs (Data Transfer Objects)
interface CreateUserDTO {
email: string;
password: string;
firstName: string;
lastName: string;
}
// Response omits sensitive fields
type UserResponse = Omit<CreateUserDTO, "password">;
// Update only allows certain fields
type UpdateUserDTO = Pick<CreateUserDTO, "firstName" | "lastName">;
// Database model includes extras
type UserModel = CreateUserDTO & {
id: number;
createdAt: Date;
updatedAt: Date;
passwordHash: string;
};
1.4 Advanced Generics
Generics let you write flexible, reusable code with type safety:
// Basic generic
function identity<T>(value: T): T {
return value;
}
// Generic with constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice" };
const name = getProperty(user, "name"); // string
// const age = getProperty(user, "age"); // ❌ Type error
// Practical: Generic API client
class ApiClient<T> {
constructor(private baseUrl: string) {}
async get(path: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`);
return response.json();
}
async post(path: string, body: Partial<T>): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
}
interface User {
id: number;
email: string;
name: string;
}
const userApi = new ApiClient<User>("https://api.example.com");
const user = await userApi.get("/users/1"); // User
await userApi.post("/users", { email: "new@example.com", name: "Bob" }); // User
// Generic with multiple constraints
interface HasId {
id: number;
}
interface HasEmail {
email: string;
}
function merge<T extends HasId, U extends HasEmail>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge(
{ id: 1, name: "Alice" },
{ email: "alice@example.com", bio: "Developer" }
);
// { id: 1, name: "Alice", email: "alice@example.com", bio: "Developer" }
Part 2: Real-World Application Patterns
2.1 Branded Types (Preventing ID Mixups)
In production, it’s easy to accidentally pass the wrong ID:
// ❌ Weak: Both are just 'number'
function getUser(userId: number) { /* ... */ }
function getPost(postId: number) { /* ... */ }
const userId = 1;
const postId = 2;
getPost(userId); // ❌ Wrong but TypeScript doesn't catch it
// ✅ Strong: Branded types prevent mistakes
type UserId = number & { readonly __brand: "UserId" };
type PostId = number & { readonly __brand: "PostId" };
const createUserId = (id: number): UserId => id as UserId;
const createPostId = (id: number): PostId => id as PostId;
function getUser(userId: UserId) { /* ... */ }
function getPost(postId: PostId) { /* ... */ }
getPost(createUserId(1)); // ❌ Type error
getPost(createPostId(2)); // ✅ Correct
2.2 Exhaustiveness Checking
Ensure you handle all cases:
type UserRole = "admin" | "moderator" | "user";
function getPermissions(role: UserRole): string[] {
switch (role) {
case "admin":
return ["read", "write", "delete", "manage-users"];
case "moderator":
return ["read", "write", "delete"];
case "user":
return ["read"];
default:
// If a new role is added, this error appears
const _exhaustive: never = role;
return _exhaustive;
}
}
// Add a new role
type UserRole = "admin" | "moderator" | "user" | "guest";
// ❌ Now getPermissions has a compile error until you handle "guest"
2.3 Builder Pattern with Types
Build complex objects safely:
interface DatabaseConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
ssl: boolean;
maxConnections: number;
}
class DatabaseConfigBuilder {
private config: Partial<DatabaseConfig> = {};
setHost(host: string): this {
this.config.host = host;
return this;
}
setPort(port: number): this {
this.config.port = port;
return this;
}
setCredentials(username: string, password: string): this {
this.config.username = username;
this.config.password = password;
return this;
}
setDatabase(database: string): this {
this.config.database = database;
return this;
}
setSSL(ssl: boolean): this {
this.config.ssl = ssl;
return this;
}
setMaxConnections(max: number): this {
this.config.maxConnections = max;
return this;
}
build(): DatabaseConfig {
const required: (keyof DatabaseConfig)[] = [
"host",
"port",
"username",
"password",
"database",
];
for (const field of required) {
if (!(field in this.config)) {
throw new Error(`Missing required field: ${field}`);
}
}
return this.config as DatabaseConfig;
}
}
// Usage
const dbConfig = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setCredentials("user", "password")
.setDatabase("myapp")
.setSSL(true)
.setMaxConnections(10)
.build();
2.4 Type-Safe Event Emitters
Create strongly-typed event systems:
interface EventMap {
"user:created": { userId: number; email: string };
"user:deleted": { userId: number };
"post:published": { postId: number; title: string };
}
class EventEmitter<Events extends Record<string, any>> {
private listeners: Map<string, Set<Function>> = new Map();
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners.has(String(event))) {
this.listeners.set(String(event), new Set());
}
this.listeners.get(String(event))!.add(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const listeners = this.listeners.get(String(event));
if (listeners) {
listeners.forEach((listener) => listener(payload));
}
}
}
// Usage
const emitter = new EventEmitter<EventMap>();
emitter.on("user:created", (payload) => {
console.log(`User ${payload.userId} created with email ${payload.email}`);
});
emitter.emit("user:created", { userId: 1, email: "alice@example.com" });
// ❌ This would be a type error:
// emitter.emit("user:created", { userId: 1 }); // Missing 'email'
Part 3: Advanced Patterns for Backend APIs
3.1 Type-Safe Express Middleware
import { Request, Response, NextFunction } from "express";
// Extend Express Request with custom properties
declare global {
namespace Express {
interface Request {
userId?: number;
user?: { id: number; email: string; role: string };
correlationId?: string;
}
}
}
// Type-safe middleware
const authenticateToken = (
req: Request,
res: Response,
next: NextFunction
): void => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
res.status(401).json({ error: "Missing token" });
return;
}
try {
const decoded = verifyToken(token);
req.userId = decoded.id;
req.user = decoded;
next();
} catch (err) {
res.status(403).json({ error: "Invalid token" });
}
};
const requireAdmin = (
req: Request,
res: Response,
next: NextFunction
): void => {
if (req.user?.role !== "admin") {
res.status(403).json({ error: "Admin access required" });
return;
}
next();
};
// Type-safe route handler
interface CreateUserRequest {
email: string;
password: string;
name: string;
}
interface UserResponse {
id: number;
email: string;
name: string;
}
app.post<never, UserResponse, CreateUserRequest>(
"/api/users",
async (req: Request<never, UserResponse, CreateUserRequest>, res) => {
const user = await createUser(req.body);
res.json(user);
}
);
3.2 Generic Repository Pattern
interface Entity {
id: number;
createdAt: Date;
updatedAt: Date;
}
abstract class BaseRepository<T extends Entity> {
constructor(protected db: Database, protected tableName: string) {}
async findById(id: number): Promise<T | null> {
return this.db.query(`SELECT * FROM ${this.tableName} WHERE id = $1`, [id]);
}
async findAll(): Promise<T[]> {
return this.db.query(`SELECT * FROM ${this.tableName}`);
}
async create(data: Omit<T, keyof Entity>): Promise<T> {
const keys = Object.keys(data);
const values = Object.values(data);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(",");
return this.db.query(
`INSERT INTO ${this.tableName} (${keys.join(",")}) VALUES (${placeholders}) RETURNING *`,
values
);
}
async update(id: number, data: Partial<Omit<T, keyof Entity>>): Promise<T> {
const keys = Object.keys(data);
const values = Object.values(data);
const setClauses = keys.map((key, i) => `${key} = $${i + 1}`).join(",");
return this.db.query(
`UPDATE ${this.tableName} SET ${setClauses}, updated_at = NOW() WHERE id = $${keys.length + 1} RETURNING *`,
[...values, id]
);
}
async delete(id: number): Promise<void> {
await this.db.query(`DELETE FROM ${this.tableName} WHERE id = $1`, [id]);
}
}
// Usage
interface User extends Entity {
email: string;
name: string;
role: "admin" | "user";
}
class UserRepository extends BaseRepository<User> {
constructor(db: Database) {
super(db, "users");
}
async findByEmail(email: string): Promise<User | null> {
return this.db.query(`SELECT * FROM users WHERE email = $1`, [email]);
}
}
const userRepo = new UserRepository(db);
const user = await userRepo.findById(1); // Correctly typed as User | null
await userRepo.create({ email: "alice@example.com", name: "Alice", role: "user" });
3.3 Type-Safe Dependency Injection
interface ServiceContainer {
database: Database;
redis: Redis;
logger: Logger;
emailService: EmailService;
userRepository: UserRepository;
}
class Container {
private services: Map<string, any> = new Map();
register<K extends keyof ServiceContainer>(
key: K,
factory: (container: this) => ServiceContainer[K]
): void {
this.services.set(String(key), factory);
}
get<K extends keyof ServiceContainer>(key: K): ServiceContainer[K] {
const factory = this.services.get(String(key));
if (!factory) {
throw new Error(`Service not found: ${String(key)}`);
}
return factory(this);
}
}
// Setup
const container = new Container();
container.register("database", () => new Database(config));
container.register("redis", () => new Redis(config));
container.register("logger", () => new Logger());
container.register("emailService", (c) => new EmailService(c.get("logger")));
container.register("userRepository", (c) => new UserRepository(c.get("database")));
// Usage
const userRepo = container.get("userRepository"); // Correctly typed
const logger = container.get("logger"); // Correctly typed
Part 4: Debugging and Common Pitfalls
4.1 Type Inference Gotchas
// ❌ Pitfall: Inferred as literal types
const config = {
host: "localhost", // string, not "localhost"
port: 5432, // number, not 5432
ssl: true, // boolean, not true
};
type Config = typeof config;
// { host: string; port: number; ssl: boolean }
// ✅ Fix 1: Use 'as const'
const config = {
host: "localhost",
port: 5432,
ssl: true,
} as const;
type Config = typeof config;
// { readonly host: "localhost"; readonly port: 5432; readonly ssl: true }
// ✅ Fix 2: Explicit types
interface Config {
host: "localhost" | "production" | "staging";
port: number;
ssl: boolean;
}
const config: Config = {
host: "localhost",
port: 5432,
ssl: true,
};
4.2 Generic Constraint Mistakes
// ❌ Wrong: Constraints are too loose
function processArray<T extends any[]>(arr: T) {
// What can we do with T? Nothing, really.
arr.forEach((item) => {
// item is any
});
}
// ✅ Better: Specify element type
function processArray<T extends any[]>(arr: T) {
arr.forEach((item: T extends (infer U)[] ? U : never) => {
// Now item has proper type
});
}
// ✅ Best: Use proper syntax
function processArray<T>(arr: T[]): void {
arr.forEach((item: T) => {
// item is T
});
}
4.3 Function Overloads for Complex APIs
// ❌ Hard to type a function that accepts multiple input shapes
function createUser(data: any) {
// ...
}
// ✅ Use function overloads
function createUser(email: string, name: string): Promise<User>;
function createUser(data: CreateUserDTO): Promise<User>;
function createUser(emailOrData: string | CreateUserDTO, name?: string): Promise<User> {
if (typeof emailOrData === "string") {
// Create from email and name
return db.users.create({ email: emailOrData, name: name! });
} else {
// Create from DTO
return db.users.create(emailOrData);
}
}
// Usage: Both are type-safe
await createUser("alice@example.com", "Alice");
await createUser({ email: "bob@example.com", name: "Bob" });
Part 5: Configuration and Build Setup
5.1 TypeScript Config for Production
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
5.2 Project Structure for Large Codebases
src/
domain/ # Business logic
user/
User.ts # Entity
UserService.ts # Business rules
UserRepository.ts # Data access
infrastructure/ # External integrations
database/
Database.ts
cache/
Redis.ts
api/ # HTTP layer
routes/
userRoutes.ts
middleware/
auth.ts
controllers/
UserController.ts
config/
database.ts
env.ts
types/
index.ts # Global types
main.ts # Entry point
🎯 TypeScript Best Practices Checklist
✅ Enable strict mode
✅ Use discriminated unions for complex states
✅ Leverage generics for reusable code
✅ Create branded types for domain concepts (UserId, PostId)
✅ Use exhaustiveness checking with switch statements
✅ Extend global types for Express/Request (augmentation)
✅ Keep types close to where they're used
✅ Prefer types over interfaces for unions
✅ Use const assertions for literal types
✅ Document complex types with comments
❌ Avoid 'any' (use 'unknown' instead)
❌ Don't use default exports (harder to refactor)
❌ Don't over-engineer types (pragmatism > perfection)
Conclusion 🚀
TypeScript mastery is about knowing which patterns solve which problems. Start with discriminated unions and branded types—they’ll prevent the most common bugs. Add generics and conditional types as your codebase grows.
The investment in learning TypeScript deeply pays dividends: fewer production bugs, easier refactoring, and code that documents itself.
Type safety is a superpower. Use it. 💪