TypeScript Advanced Patterns: Generics, Utility Types, and Type-Safe APIs
Master TypeScript advanced patterns — conditional types, mapped types, template literal types, branded types, and type-safe API clients. Real examples for produ
TypeScript Advanced Patterns: Generics, Utility Types, and Type-Safe APIs
TypeScript's basic types — string, number, interface, type — cover 80% of everyday code. The other 20% is where TypeScript gets genuinely powerful: conditional types that branch on type relationships, mapped types that transform shapes, branded types that prevent logic errors the runtime can't catch, and generic constraints that make library code type-safe without sacrificing flexibility.
This guide covers the patterns that show up in production TypeScript codebases: the patterns our team reaches for when building type-safe API clients, validation libraries, and shared utilities.
Generics Beyond the Basics
Most developers use generics for collections: Array<T>, Promise<T>. The real leverage comes from using type parameters to propagate type information through functions.
Generic with constraint:
// Without constraint — T can be anything, no property access
function getProperty<T>(obj: T, key: string): unknown {
return (obj as any)[key]; // Forced to use any
}
// With keyof constraint — T must have key K
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // Fully typed — return type is T[K]
}
const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const name = getProperty(user, 'name'); // type: string ✅
const id = getProperty(user, 'id'); // type: number ✅
// getProperty(user, 'phone'); // Error: not in keyof User ✅
Generic with default type parameter (TypeScript 5.5+):
// Reusable paginated response — generic with sensible default
interface PaginatedResponse<T = Record<string, unknown>> {
data: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}
// Without explicit T — returns PaginatedResponse<Record<string, unknown>>
// With T — fully typed
type UserListResponse = PaginatedResponse<User>;
type ProductListResponse = PaginatedResponse<Product>;
Conditional Types
Conditional types let you create types that branch based on type relationships — the extends check acts like a ternary for types.
// Basic conditional type
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
// Infer — extract types from within other types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type C = UnwrapPromise<Promise<string>>; // string
type D = UnwrapPromise<string>; // string (unchanged)
// Practical: extract return type of any async function
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
T extends (...args: any[]) => Promise<infer R> ? R : never;
async function fetchUser(id: string) {
return { id, name: 'Alice', email: 'alice@example.com' };
}
type FetchedUser = AsyncReturnType<typeof fetchUser>;
// type FetchedUser = { id: string; name: string; email: string; }
// Distributive conditional types — distribute over union members
type NonNullable<T> = T extends null | undefined ? never : T;
type E = NonNullable<string | null | undefined>; // string
Practical example — API error handling:
type Result<T, E extends Error = Error> =
| { ok: true; data: T }
| { ok: false; error: E };
// Type-safe unwrap
function unwrap<T>(result: Result<T>): T {
if (!result.ok) throw result.error;
return result.data;
}
// Narrow based on ok
async function getUser(id: string): Promise<Result<User>> {
try {
const user = await db.user.findUnique({ where: { id } });
if (!user) return { ok: false, error: new Error('User not found') };
return { ok: true, data: user };
} catch (err) {
return { ok: false, error: err as Error };
}
}
const result = await getUser('123');
if (result.ok) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error.message); // TypeScript knows error exists
}
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Mapped Types
Mapped types transform the shape of existing types — creating new types by iterating over the keys of another type.
// The built-in Partial, Required, Readonly are all mapped types:
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] }; // -? removes optionality
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Custom: make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface CreateUserInput {
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
avatarUrl: string;
}
// role and avatarUrl become optional — rest remain required
type CreateUserInputOptional = PartialBy<CreateUserInput, 'role' | 'avatarUrl'>;
// Custom: transform all values to a different type
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Stringify<T> = { [K in keyof T]: string };
// Remapping keys with 'as' clause (TypeScript 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User { id: string; name: string; email: string; }
type UserGetters = Getters<User>;
// { getId: () => string; getName: () => string; getEmail: () => string; }
Template Literal Types
Template literals in types enable string manipulation at the type level.
// Event name unions
type EventPrefix = 'user' | 'order' | 'payment';
type EventAction = 'created' | 'updated' | 'deleted';
type EventName = `${EventPrefix}.${EventAction}`;
// 'user.created' | 'user.updated' | 'user.deleted' | 'order.created' | ...
// Type-safe event emitter
type EventMap = {
'user.created': { userId: string; email: string };
'user.deleted': { userId: string };
'order.created': { orderId: string; total: number };
'order.paid': { orderId: string; paymentIntentId: string };
};
class TypedEventEmitter {
private listeners = new Map<string, Function[]>();
on<K extends keyof EventMap>(
event: K,
listener: (data: EventMap[K]) => void
): void {
const existing = this.listeners.get(event) ?? [];
this.listeners.set(event, [...existing, listener]);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
this.listeners.get(event)?.forEach(listener => listener(data));
}
}
const emitter = new TypedEventEmitter();
emitter.on('user.created', ({ userId, email }) => { // Fully typed params
console.log(userId, email);
});
// emitter.emit('user.created', { orderId: '...' }); // Error! Wrong shape
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Branded Types
Branded types prevent accidentally passing one primitive type where another is expected — catching logic bugs at compile time that TypeScript would otherwise allow.
// Without branding — all strings are interchangeable
function sendEmail(to: string, from: string) { /* ... */ }
sendEmail(fromAddress, toAddress); // Swapped args — TypeScript doesn't catch this!
// With branded types
declare const __brand: unique symbol;
type Brand<B, T> = T & { [__brand]: B };
type UserId = Brand<'UserId', string>;
type Email = Brand<'Email', string>;
type OrderId = Brand<'OrderId', string>;
// Constructor functions validate and brand
function createUserId(raw: string): UserId {
if (!raw.match(/^[0-9a-f-]{36}$/)) throw new Error('Invalid UUID');
return raw as UserId;
}
function createEmail(raw: string): Email {
if (!raw.includes('@')) throw new Error('Invalid email');
return raw.toLowerCase() as Email;
}
function sendEmail(to: Email, from: Email) { /* ... */ }
const email = createEmail('alice@example.com');
const userId = createUserId('550e8400-e29b-41d4-a716-446655440000');
sendEmail(email, email); // ✅
// sendEmail(userId, email); // Error: UserId is not assignable to Email ✅
// sendEmail('raw-string', email); // Error: string is not Email ✅
Branded types for monetary values (prevent cents/dollars confusion):
type Cents = Brand<'Cents', number>;
type Dollars = Brand<'Dollars', number>;
function toCents(dollars: Dollars): Cents {
return Math.round(dollars * 100) as Cents;
}
function formatPrice(price: Cents): string {
return `$${(price / 100).toFixed(2)}`;
}
// API returns prices in cents
const priceInCents = 1999 as Cents; // $19.99
formatPrice(priceInCents); // ✅
// formatPrice(19.99 as Dollars); // Error: Dollars is not Cents ✅
Type-Safe API Client
Combining generics, conditional types, and template literals to build a fully typed API client:
// types/api.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface ApiRoute<
TPath extends string = string,
TMethod extends HttpMethod = HttpMethod,
TBody = never,
TResponse = unknown,
TQuery = never,
> {
path: TPath;
method: TMethod;
body: TBody;
response: TResponse;
query: TQuery;
}
// Define all routes in one place
export type ApiRoutes = {
'GET /users': ApiRoute<'/users', 'GET', never, User[], { page?: number; limit?: number }>;
'GET /users/:id': ApiRoute<'/users/:id', 'GET', never, User, never>;
'POST /users': ApiRoute<'/users', 'POST', CreateUserInput, User, never>;
'PUT /users/:id': ApiRoute<'/users/:id', 'PUT', UpdateUserInput, User, never>;
'DELETE /users/:id': ApiRoute<'/users/:id', 'DELETE', never, void, never>;
'GET /orders': ApiRoute<'/orders', 'GET', never, Order[], { status?: OrderStatus }>;
'POST /orders': ApiRoute<'/orders', 'POST', CreateOrderInput, Order, never>;
};
type RouteKey = keyof ApiRoutes;
// Extract types from route key
type RouteBody<K extends RouteKey> = ApiRoutes[K]['body'];
type RouteResponse<K extends RouteKey> = ApiRoutes[K]['response'];
type RouteQuery<K extends RouteKey> = ApiRoutes[K]['query'];
// Type-safe client
class ApiClient {
constructor(private baseUrl: string, private token?: string) {}
async request<K extends RouteKey>(
route: K,
options: {
params?: Record<string, string>;
body?: RouteBody<K> extends never ? never : RouteBody<K>;
query?: RouteQuery<K> extends never ? never : RouteQuery<K>;
} = {}
): Promise<RouteResponse<K>> {
const [method, pathTemplate] = (route as string).split(' ');
let path = pathTemplate;
if (options.params) {
Object.entries(options.params).forEach(([k, v]) => {
path = path.replace(`:${k}`, v);
});
}
const url = new URL(path, this.baseUrl);
if (options.query) {
Object.entries(options.query as Record<string, string>).forEach(([k, v]) => {
if (v !== undefined) url.searchParams.set(k, String(v));
});
}
const res = await fetch(url.toString(), {
method,
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<RouteResponse<K>>;
}
}
// Usage — fully typed, no casting
const api = new ApiClient('https://api.example.com', token);
const users = await api.request('GET /users', { query: { page: 1, limit: 20 } });
// users: User[] ✅
const user = await api.request('GET /users/:id', { params: { id: '123' } });
// user: User ✅
const newUser = await api.request('POST /users', {
body: { name: 'Alice', email: 'alice@example.com' },
});
// newUser: User ✅
Utility Types Reference
| Built-in | What It Does | Example |
|---|---|---|
Partial<T> | All fields optional | Partial<User> |
Required<T> | All fields required | Required<CreateInput> |
Readonly<T> | All fields readonly | Readonly<Config> |
Pick<T, K> | Keep only K keys | Pick<User, 'id' | 'name'> |
Omit<T, K> | Remove K keys | Omit<User, 'passwordHash'> |
Record<K, V> | Object with K keys, V values | Record<string, number> |
Exclude<T, U> | Remove U from union T | Exclude<string | null, null> |
Extract<T, U> | Keep U in union T | Extract<string | number, string> |
NonNullable<T> | Remove null/undefined | NonNullable<string | null> |
ReturnType<F> | Return type of function | ReturnType<typeof getUser> |
Parameters<F> | Params tuple of function | Parameters<typeof createUser> |
Awaited<T> | Unwrap Promise | Awaited<ReturnType<typeof fetch>> |
Working With Viprasol
We build TypeScript-first backends and frontends where type safety is a feature of the system, not just a linting tool. Our shared type libraries — API contracts, event schemas, domain models — eliminate entire categories of runtime errors before code is deployed.
→ Talk to our TypeScript team about your codebase.
See Also
- TypeScript Development Company — TypeScript for large-scale applications
- Node.js vs Python Backend — when TypeScript backend wins
- API Gateway Patterns — type-safe API routing and middleware
- GraphQL API Development — type-safe GraphQL with TypeScript
- Web Development Services — TypeScript development and architecture
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.