Advanced TypeScript Patterns for Large-Scale Applications
Last updated: October 2025 β’ Reviewed by TypeScript experts
TypeScript isn't just "JavaScript with types." When you master advanced patterns, it becomes a powerful tool for catching bugs at compile time, enforcing architectural decisions, and building maintainable large-scale applications. I've seen TypeScript transform codebases from maintenance nightmares into well-documented, self-checking systems.
This guide goes beyond the basics. We'll explore generics that actually make sense, utility types that save hours of work, and patterns used in production by companies like Microsoft, Google, and Airbnb. Plus, we'll cover critical security implications of type safety that most developers miss.
π§¬Generics: Write Once, Type Safely Everywhere
Generics are probably the most powerful feature in TypeScript. They let you write reusable code that works with any type while maintaining type safety. Think of them as type variables - placeholders that get filled in when you use the function or class.
Basic Generic Function:
// Without generics - need different functions for each type
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
// With generics - one function for all types
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
// TypeScript infers the type automatically
const firstNum = getFirst([1, 2, 3]); // number | undefined
const firstStr = getFirst(['a', 'b']); // string | undefined
const firstObj = getFirst([{ id: 1 }]); // { id: number } | undefinedGeneric Constraints:
// Constrain generic to objects with 'id' property
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
// Works with any object that has an id
const users = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
const posts = [{ id: 1, title: 'Hello', body: '...' }];
findById(users, 1); // Works!
findById(posts, 1); // Works!
// findById(['a', 'b'], 1); // Error: string doesn't have 'id'Real-World: API Response Handler
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
return response.json();
}
// Type-safe API calls
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
userId: number;
}
// TypeScript knows the exact shape of data
const userResponse = await fetchApi<User>('/api/users/1');
console.log(userResponse.data.name); // β Type-safe!
const postsResponse = await fetchApi<Post[]>('/api/posts');
console.log(postsResponse.data[0].title); // β Type-safe!π οΈUtility Types: TypeScript's Hidden Gems
TypeScript comes with built-in utility types that save you from writing boilerplate code. These are game-changers for maintaining large codebases.
Partial<T>
Makes all properties optional
interface User {
id: number;
name: string;
email: string;
}
// For updates - not all fields required
function updateUser(
id: number,
updates: Partial<User>
) {
// Only update provided fields
}
updateUser(1, { name: 'John' }); // β
updateUser(1, { email: 'j@x.com' }); // βPick<T, K>
Select specific properties
// Only expose safe fields
type PublicUser = Pick<User,
'id' | 'name'
>;
function getPublicProfile(
user: User
): PublicUser {
return {
id: user.id,
name: user.name
// email not included
};
}Omit<T, K>
Exclude specific properties
// Remove sensitive fields
type SafeUser = Omit<User,
'password' | 'ssn'
>;
function sanitizeUser(
user: User
): SafeUser {
const { password, ssn, ...safe } = user;
return safe;
}Record<K, T>
Create object types with known keys
type Role = 'admin' | 'user' | 'guest';
type Permissions = Record<
Role,
string[]
>;
const perms: Permissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
};πTypeScript Security: Type Safety Isn't Enough
Here's a hard truth that many TypeScript developers don't realize: type safety doesn't protect you from security vulnerabilities. I've audited codebases where teams felt secure because they used TypeScript, but they had critical XSS vulnerabilities, SQL injection risks, and authentication bypasses. TypeScript is a powerful tool, but it's not a security silver bullet.
The problem is that types are erased at runtime. All your beautiful type definitions vanish when the code compiles to JavaScript. This means malicious input, API responses with unexpected shapes, or compromised dependencies can bypass your type system entirely. You need runtime validation, not just compile-time checking.
Let me show you exactly where developers get this wrong and how to fix it. These patterns have stopped real security breaches in production systems I've worked on.
The False Sense of Security
Consider this common pattern. It looks type-safe, but it's dangerously vulnerable:
// Looks safe, but it's NOT
interface User {
id: number;
email: string;
isAdmin: boolean;
}
app.post('/api/update-profile', async (req, res) => {
// TypeScript says this is safe
const userData: User = req.body;
// But attacker can send:
// { id: 123, email: "fake@evil.com", isAdmin: true }
// TypeScript won't catch this at runtime!
await db.users.update(userData.id, userData);
// Boom - attacker just made themselves admin
});The type annotation User is just a hint to the compiler. At runtime, TypeScript has no way to verify that req.body actually matches the User interface. An attacker can send any JSON they want, and TypeScript will happily compile your code without complaint.
Runtime Validation: The Real Solution
You need libraries that validate data at runtime. My go-to choices are Zod, Yup, or io-ts. They bridge the gap between compile-time types and runtime safety:
import { z } from 'zod';
// Define schema with runtime validation
const UserUpdateSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
// isAdmin is NOT allowed in updates!
}).strict();
app.post('/api/update-profile', async (req, res) => {
try {
// Validate at runtime
const userData = UserUpdateSchema.parse(req.body);
// If we get here, data is ACTUALLY safe
await db.users.update(req.user.id, userData);
res.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
// Detailed validation errors
res.status(400).json({
error: 'Invalid input',
details: error.errors
});
}
}
});
// Bonus: Extract TypeScript type from schema
type UserUpdate = z.infer<typeof UserUpdateSchema>;This approach gives you both compile-time types AND runtime validation. The .strict()call is crucial - it rejects any properties not explicitly defined in the schema. Without it, attackers could still sneak in extra fields like isAdmin.
Never Trust External Data
API responses, environment variables, local storage, cookies - all of these come from outside your type system. Always validate them:
// API Response Schema
const ApiResponseSchema = z.object({
data: z.object({
id: z.number(),
email: z.string().email(),
name: z.string()
}),
status: z.number()
});
async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
// Validate before using
const validated = ApiResponseSchema.parse(json);
return validated.data;
// If API returns unexpected data, parse() throws
// Better to fail fast than use corrupted data
}
// Environment variables
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'production', 'test'])
});
// Validate on startup
const env = EnvSchema.parse(process.env);Branded Types for Critical Data
Some values need extra protection. User IDs, passwords, tokens - you don't want these confused with regular strings. Branded types help:
// Create branded types
type UserId = string & { readonly __brand: 'UserId' };
type SessionToken = string & { readonly __brand: 'SessionToken' };
// Factory functions with validation
function createUserId(id: string): UserId {
if (!/^[0-9a-f]{24}$/.test(id)) {
throw new Error('Invalid user ID format');
}
return id as UserId;
}
function createSessionToken(token: string): SessionToken {
if (token.length < 32) {
throw new Error('Invalid session token');
}
return token as SessionToken;
}
// Now you can't mix them up
function getUser(id: UserId) { /* ... */ }
function validateSession(token: SessionToken) { /* ... */ }
const userId = createUserId('507f1f77bcf86cd799439011');
const token = createSessionToken('secure-token-here...');
getUser(userId); // β Works
// getUser(token); // β Type error - can't pass SessionToken as UserId
// getUser('random-string'); // β Type error - must use createUserId()π¨Design Patterns with TypeScript
Builder Pattern with Type Safety:
class QueryBuilder<T> {
private conditions: string[] = [];
where(field: keyof T, value: any): this {
this.conditions.push(`${String(field)} = ${value}`);
return this;
}
orderBy(field: keyof T, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.conditions.push(`ORDER BY ${String(field)} ${direction}`);
return this;
}
build(): string {
return this.conditions.join(' ');
}
}
interface User {
id: number;
name: string;
email: string;
}
// Type-safe query building
const query = new QueryBuilder<User>()
.where('email', 'test@test.com')
.orderBy('name', 'ASC')
.build();
// query.where('invalid', 'value'); // Error: 'invalid' not in UserDiscriminated Unions for State Management:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function handleRequest<T>(state: RequestState<T>) {
switch (state.status) {
case 'idle':
return 'Not started yet';
case 'loading':
return 'Loading...';
case 'success':
// TypeScript knows state.data exists here!
return `Got data: ${state.data}`;
case 'error':
// TypeScript knows state.error exists here!
return `Error: ${state.error}`;
}
}
// Usage
const userState: RequestState<User> = {
status: 'success',
data: { id: 1, name: 'John', email: 'j@x.com' }
};β TypeScript Best Practices Checklist
π― Key Takeaway
Advanced TypeScript is about more than fancy types - it's about building systems that are both maintainable and secure. Use generics to reduce code duplication, leverage utility types to express intent clearly, and always remember: type safety at compile time doesn't protect you at runtime. Validate everything that crosses your application boundary.
About the Authors
Written by DevMetrix's TypeScript team with experience building type-safe applications at scale. We've migrated dozens of JavaScript codebases to TypeScript and fixed countless type-related bugs in production systems.
β TypeScript experts β’ β Updated October 2025 β’ β Security-reviewed practices
π§Build Type-Safe APIs
Test your TypeScript APIs with proper type checking. Use our API tester to verify your endpoints return the expected data shapes.
Try API Tester β