- Published on
Advanced TypeScript Patterns Mastery: From Generics to Type Gymnastics — 2025 Practical Guide
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. TypeScript 5.x New Features (2024-2025)
- 2. Generics Master Class
- 3. Conditional Types Deep Dive
- 4. Mapped Types and Template Literal Types
- 5. Branded Types and Phantom Types
- 6. Ten Type-Safe Patterns
- Pattern 1: Discriminated Unions for State Machines
- Pattern 2: Exhaustive Checking with never
- Pattern 3: Type-safe Event Emitter
- Pattern 4: Builder Pattern with Type Accumulation
- Pattern 5: Const Assertions and Readonly Tuples
- Pattern 6: Variadic Tuple Types
- Pattern 7: Type-safe ORM Queries
- Pattern 8: Recursive Types (JSON, Tree)
- Pattern 9: HKT (Higher-Kinded Types) Emulation
- Pattern 10: Module Augmentation and Declaration Merging
- 7. Zod + tRPC: Runtime + Compile-time Type Safety
- 8. TypeScript Performance Optimization
- 9. Type Gymnastics (Type Challenges) Practice
- 10. Quiz
- 11. References
Introduction
TypeScript has become the standard of the JavaScript ecosystem. With rapid evolution from TypeScript 5.4 through 5.7 during 2024-2025, the expressiveness of the type system has become even more powerful. Modern TypeScript development goes beyond simply adding types — it's about implementing logic at the type level and ensuring runtime safety.
This guide systematically covers advanced TypeScript patterns from generics basics through Conditional Types, Mapped Types, Template Literal Types, Branded Types, Zod + tRPC integration, and Type Challenges. Each section includes practical code examples you can apply immediately.
1. TypeScript 5.x New Features (2024-2025)
TypeScript 5.4 — NoInfer and groupBy
The most notable feature in TypeScript 5.4 is the NoInfer utility type. It controls whether a specific parameter participates in type inference for generic functions.
// Without NoInfer — defaultValue influences T inference
function getOrDefault<T>(value: T | undefined, defaultValue: T): T {
return value ?? defaultValue
}
// With NoInfer — defaultValue doesn't participate in T inference
function getOrDefault<T>(value: T | undefined, defaultValue: NoInfer<T>): T {
return value ?? defaultValue
}
// Usage
const result = getOrDefault('hello', 42)
// Without NoInfer: T = string | number
// With NoInfer: T = string, error on 42
Object.groupBy and Map.groupBy are now officially supported:
const users = [
{ name: 'Alice', role: 'admin' },
{ name: 'Bob', role: 'user' },
{ name: 'Charlie', role: 'admin' },
]
const grouped = Object.groupBy(users, (user) => user.role)
// Type: Partial<Record<string, User[]>>
TypeScript 5.5 — Inferred Type Predicates
TypeScript 5.5 allows the compiler to automatically infer type narrowing without explicit type guard declarations.
// Before: explicit type guard required
function isString(value: unknown): value is string {
return typeof value === 'string'
}
// TS 5.5: automatic inference
const values = [1, 'hello', null, 'world', 3]
const strings = values.filter((v) => typeof v === 'string')
// In TS 5.5, strings is string[] (previously (string | number | null)[])
The isolatedDeclarations option enables delegating .d.ts generation to external tools (SWC, oxc, etc.):
{
"compilerOptions": {
"isolatedDeclarations": true,
"declaration": true
}
}
TypeScript 5.6 — Iterator Helpers
Support for the ECMAScript Iterator Helpers proposal enables chaining-style iterator manipulation:
function* fibonacci(): Generator<number> {
let a = 0,
b = 1
while (true) {
yield a
;[a, b] = [b, a + b]
}
}
// Iterator helpers (TS 5.6)
const first10Even = fibonacci()
.filter((n) => n % 2 === 0)
.take(10)
.toArray()
The --noUncheckedSideEffectImports flag verifies the existence of side-effect imports:
// Error if this file doesn't exist
import './polyfills.js'
import 'some-module/register'
TypeScript 5.7 — ES2024 Target and Path Rewriting
{
"compilerOptions": {
"target": "es2024",
"rewriteRelativeImportExtensions": true
}
}
The rewriteRelativeImportExtensions option automatically converts .ts imports to .js in output:
// Source code
import { helper } from './utils.ts'
// Compiled output
import { helper } from './utils.js'
2. Generics Master Class
Basic Generics
Generics are a core TypeScript feature that creates reusable code by accepting types as parameters.
// The most basic generic function
function identity<T>(arg: T): T {
return arg
}
// Generic interface
interface Box<T> {
value: T
map<U>(fn: (value: T) => U): Box<U>
}
function createBox<T>(value: T): Box<T> {
return {
value,
map: (fn) => createBox(fn(value)),
}
}
const numberBox = createBox(42) // Box<number>
const stringBox = numberBox.map(String) // Box<string>
Constraints (extends)
// Basic constraint
function getLength<T extends { length: number }>(arg: T): number {
return arg.length
}
getLength('hello') // OK
getLength([1, 2, 3]) // OK
getLength(42) // Error: number has no length
// keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { name: 'Alice', age: 30 }
getProperty(user, 'name') // string
getProperty(user, 'age') // number
getProperty(user, 'foo') // Error: 'foo' is not keyof User
Generic Defaults
interface PaginatedResponse<T, Meta = { page: number; total: number }> {
data: T[]
meta: Meta
}
// Using Meta default
const response: PaginatedResponse<User> = {
data: [{ name: 'Alice', age: 30 }],
meta: { page: 1, total: 100 },
}
// Customizing Meta
const cursorResponse: PaginatedResponse<User, { cursor: string; hasMore: boolean }> = {
data: [{ name: 'Bob', age: 25 }],
meta: { cursor: 'abc123', hasMore: true },
}
Practical Example: API Response Wrapper
// API response type system
interface ApiResponse<T> {
success: true
data: T
timestamp: number
}
interface ApiError {
success: false
error: {
code: string
message: string
}
timestamp: number
}
type ApiResult<T> = ApiResponse<T> | ApiError
// Type-safe API client
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
const response = await fetch(url)
return response.json()
}
// Usage
interface User {
id: number
name: string
email: string
}
const result = await fetchApi<User>('/api/users/1')
if (result.success) {
console.log(result.data.name) // Type-safe: User
} else {
console.error(result.error.message) // Type-safe: ApiError
}
Practical Example: Repository Pattern
interface Entity {
id: string
createdAt: Date
updatedAt: Date
}
interface Repository<T extends Entity> {
findById(id: string): Promise<T | null>
findMany(filter: Partial<T>): Promise<T[]>
create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>
update(id: string, data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T>
delete(id: string): Promise<void>
}
// Implementation
interface User extends Entity {
name: string
email: string
role: 'admin' | 'user'
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return null
}
async findMany(filter: Partial<User>): Promise<User[]> {
return []
}
async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
return {} as User
}
async update(
id: string,
data: Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<User> {
return {} as User
}
async delete(id: string): Promise<void> {}
}
Practical Example: Builder Pattern
class QueryBuilder<T extends Record<string, unknown>> {
private conditions: string[] = []
private selectedFields: string[] = []
select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
this.selectedFields = fields as string[]
return this as unknown as QueryBuilder<Pick<T, K>>
}
where<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push(`${String(field)} = ${JSON.stringify(value)}`)
return this
}
build(): { fields: string[]; conditions: string[] } {
return {
fields: this.selectedFields,
conditions: this.conditions,
}
}
}
// Usage
interface Product {
id: number
name: string
price: number
category: string
}
const query = new QueryBuilder<Product>()
.select('name', 'price')
.where('category', 'electronics')
.build()
Generic Inference (infer) Deep Dive
// Extract function return type
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never
// Extract Promise inner type
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
// Extract array element type
type ElementOf<T> = T extends (infer E)[] ? E : never
// Practical: Using multiple infer together
type FunctionParts<T> = T extends (...args: infer A) => infer R ? { args: A; return: R } : never
type Parts = FunctionParts<(name: string, age: number) => boolean>
// { args: [string, number]; return: boolean }
3. Conditional Types Deep Dive
Extracting Types with the infer Keyword
The infer keyword in Conditional Types serves to "capture" types. It's essential for extracting desired parts from complex types.
// Extract first parameter type of a function
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never
type P = FirstParam<(name: string, age: number) => void> // string
// Extract constructor's instance type
type InstanceOf<T> = T extends new (...args: any[]) => infer I ? I : never
class MyClass {
value = 42
}
type Instance = InstanceOf<typeof MyClass> // MyClass
Recursive Conditional Types
// Deep Readonly
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
interface Config {
db: {
host: string
port: number
credentials: {
user: string
password: string
}
}
features: string[]
}
type ReadonlyConfig = DeepReadonly<Config>
// All nested properties become readonly
// Deep Partial
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
// JSON type (recursive)
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
Distributive Conditional Types
When Conditional Types are applied to union types, they distribute across each member individually.
// Distribution applies
type ToArray<T> = T extends any ? T[] : never
type Result = ToArray<string | number>
// string[] | number[] (distributed)
// Prevent distribution by wrapping in brackets
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type Result2 = ToArrayNonDist<string | number>
// (string | number)[] (not distributed)
Practical: Extracting Data Types from API Responses
// API endpoint type definitions
interface ApiEndpoints {
'/users': { response: User[]; params: never }
'/users/:id': { response: User; params: { id: string } }
'/posts': { response: Post[]; params: never }
'/posts/:id': { response: Post; params: { id: string } }
}
// Extract response type
type ResponseOf<T extends keyof ApiEndpoints> = ApiEndpoints[T]['response']
type ParamsOf<T extends keyof ApiEndpoints> = ApiEndpoints[T]['params']
// Usage
type UserListResponse = ResponseOf<'/users'> // User[]
type UserParams = ParamsOf<'/users/:id'> // { id: string }
Promise Unwrap (Deep Unwrapping)
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T
type A = DeepAwaited<Promise<Promise<Promise<string>>>> // string
// Practical: Get the actual return type of an async function
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> = T extends (
...args: any[]
) => Promise<infer R>
? R
: never
async function fetchUser(): Promise<User> {
return {} as User
}
type FetchedUser = AsyncReturnType<typeof fetchUser> // User
4. Mapped Types and Template Literal Types
Implementing Built-in Utility Types
Understanding how TypeScript's built-in utility types work enables you to create custom utilities.
// Partial implementation
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
// Required implementation
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
// Readonly implementation
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
// Pick implementation
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// Omit implementation
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
Key Remapping (as)
In TypeScript 4.1+, the as clause enables key transformation.
// Add prefix to all keys
type Prefixed<T, Prefix extends string> = {
[K in keyof T as `${Prefix}${Capitalize<string & K>}`]: T[K]
}
interface User {
name: string
age: number
}
type PrefixedUser = Prefixed<User, 'get'>
// { getName: string; getAge: number }
// Auto-generate getter types
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type UserGetters = Getters<User>
// { getName: () => string; getAge: () => number }
// Filter keys by type
type StringKeys<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K]
}
interface Mixed {
name: string
age: number
email: string
active: boolean
}
type OnlyStrings = StringKeys<Mixed>
// { name: string; email: string }
Template Literal Types
Combine string literal types to create powerful types.
// Basic template literal
type EventName = 'click' | 'focus' | 'blur'
type EventHandler = `on${Capitalize<EventName>}`
// 'onClick' | 'onFocus' | 'onBlur'
// CSS units
type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw'
type CSSValue = `${number}${CSSUnit}`
const width: CSSValue = '100px' // OK
const height: CSSValue = '50vh' // OK
// Built-in string manipulation types
type Upper = Uppercase<'hello'> // 'HELLO'
type Lower = Lowercase<'HELLO'> // 'hello'
type Cap = Capitalize<'hello'> // 'Hello'
type Uncap = Uncapitalize<'Hello'> // 'hello'
Practical: Auto-generating Event Handler Types
interface Events {
userCreated: { userId: string; name: string }
userUpdated: { userId: string; changes: Record<string, unknown> }
orderPlaced: { orderId: string; total: number }
orderCancelled: { orderId: string; reason: string }
}
// Auto-generate event listener types
type EventListeners<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void
}
type AppEventListeners = EventListeners<Events>
// {
// onUserCreated: (payload: { userId: string; name: string }) => void;
// onUserUpdated: (payload: { userId: string; changes: ... }) => void;
// onOrderPlaced: (payload: { orderId: string; total: number }) => void;
// onOrderCancelled: (payload: { orderId: string; reason: string }) => void;
// }
Practical: Auto-generating API Route Types
// REST API method definitions
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type ApiRoute<Method extends HttpMethod, Path extends string, Body = never, Response = unknown> = {
method: Method
path: Path
body: Body
response: Response
}
// Route definitions
type Routes = {
listUsers: ApiRoute<'GET', '/api/users', never, User[]>
getUser: ApiRoute<'GET', '/api/users/:id', never, User>
createUser: ApiRoute<'POST', '/api/users', Omit<User, 'id'>, User>
updateUser: ApiRoute<'PUT', '/api/users/:id', Partial<User>, User>
deleteUser: ApiRoute<'DELETE', '/api/users/:id', never, void>
}
// Generate type-safe client
type TypeSafeClient<R extends Record<string, ApiRoute<any, any, any, any>>> = {
[K in keyof R]: R[K]['body'] extends never
? () => Promise<R[K]['response']>
: (body: R[K]['body']) => Promise<R[K]['response']>
}
type Client = TypeSafeClient<Routes>
5. Branded Types and Phantom Types
The Problem: Limitations of Structural Typing
TypeScript uses structural typing. Types with the same structure are treated as the same type.
// Problem scenario
type UserId = string
type PostId = string
function deleteUser(id: UserId): void {}
function deletePost(id: PostId): void {}
const userId: UserId = 'user-123'
const postId: PostId = 'post-456'
// Both are string, so accidentally swapping them causes no error!
deleteUser(postId) // Should error but passes
deletePost(userId) // Also passes
Solution: Branded Types
// Add a brand symbol for structural distinction
type Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, 'UserId'>
type PostId = Brand<string, 'PostId'>
type OrderId = Brand<string, 'OrderId'>
// Creation functions
function createUserId(id: string): UserId {
return id as UserId
}
function createPostId(id: string): PostId {
return id as PostId
}
// Now type-safe!
function deleteUser(id: UserId): void {}
function deletePost(id: PostId): void {}
const userId = createUserId('user-123')
const postId = createPostId('post-456')
deleteUser(userId) // OK
deleteUser(postId) // Error! PostId is not assignable to UserId
Currency Type Safety
type Currency<C extends string> = number & { readonly __currency: C }
type USD = Currency<'USD'>
type EUR = Currency<'EUR'>
type KRW = Currency<'KRW'>
function usd(amount: number): USD {
return amount as USD
}
function eur(amount: number): EUR {
return amount as EUR
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD
}
const price1 = usd(100)
const price2 = usd(50)
const priceEur = eur(80)
addUSD(price1, price2) // OK
addUSD(price1, priceEur) // Error! EUR is not assignable to USD
Database ID Type Separation
// Generic branded ID system
type EntityId<Entity extends string> = Brand<string, Entity>
type UserId = EntityId<'User'>
type PostId = EntityId<'Post'>
type CommentId = EntityId<'Comment'>
// Type-safe relationship definitions
interface Post {
id: PostId
authorId: UserId
title: string
content: string
}
interface Comment {
id: CommentId
postId: PostId
authorId: UserId
text: string
}
// Usage in repositories
function findPostsByAuthor(authorId: UserId): Promise<Post[]> {
return Promise.resolve([])
}
function findCommentsByPost(postId: PostId): Promise<Comment[]> {
return Promise.resolve([])
}
// Prevents incorrect ID usage at compile time
const userId = 'user-1' as UserId
const postId = 'post-1' as PostId
findPostsByAuthor(userId) // OK
findPostsByAuthor(postId) // Error!
Combining with Zod
import { z } from 'zod'
// Apply branded types to Zod schemas
const UserIdSchema = z.string().uuid().brand('UserId')
type UserId = z.infer<typeof UserIdSchema>
const PostIdSchema = z.string().uuid().brand('PostId')
type PostId = z.infer<typeof PostIdSchema>
// Runtime validation + type safety
const userId = UserIdSchema.parse('550e8400-e29b-41d4-a716-446655440000')
// userId type is string & Brand<'UserId'>
// Invalid format throws runtime error
try {
const invalid = UserIdSchema.parse('not-a-uuid')
} catch (e) {
console.error('Invalid UserId')
}
6. Ten Type-Safe Patterns
Pattern 1: Discriminated Unions for State Machines
// Express a state machine with types
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function renderUser(state: RequestState<User>): string {
switch (state.status) {
case 'idle':
return 'Waiting...'
case 'loading':
return 'Loading...'
case 'success':
return `User: ${state.data.name}`
case 'error':
return `Error: ${state.error.message}`
}
}
// Finite state machine
type TrafficLight =
| { state: 'red'; timer: number }
| { state: 'yellow'; timer: number }
| { state: 'green'; timer: number }
function nextState(light: TrafficLight): TrafficLight {
switch (light.state) {
case 'red':
return { state: 'green', timer: 30 }
case 'green':
return { state: 'yellow', timer: 5 }
case 'yellow':
return { state: 'red', timer: 30 }
}
}
Pattern 2: Exhaustive Checking with never
// Verify all cases are handled at compile time
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return (shape.base * shape.height) / 2
default:
return assertNever(shape)
// Compile error when a new Shape is added
}
}
Pattern 3: Type-safe Event Emitter
type EventMap = Record<string, any>
class TypedEventEmitter<T extends EventMap> {
private listeners: Partial<Record<keyof T, Function[]>> = {}
on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event]!.push(listener)
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners[event]?.forEach((fn) => fn(payload))
}
off<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
this.listeners[event] = this.listeners[event]?.filter((fn) => fn !== listener)
}
}
// Usage
interface AppEvents {
userLoggedIn: { userId: string; timestamp: Date }
orderPlaced: { orderId: string; total: number }
error: { message: string; stack?: string }
}
const emitter = new TypedEventEmitter<AppEvents>()
emitter.on('userLoggedIn', (payload) => {
console.log(payload.userId) // Type-safe: string
})
emitter.emit('orderPlaced', { orderId: '123', total: 99.99 }) // OK
emitter.emit('orderPlaced', { orderId: '123' }) // Error: total missing
Pattern 4: Builder Pattern with Type Accumulation
// Track required fields at the type level
type RequiredFields = 'host' | 'port' | 'database'
class ConnectionBuilder<Built extends string = never> {
private config: Record<string, unknown> = {}
host(value: string): ConnectionBuilder<Built | 'host'> {
this.config.host = value
return this as any
}
port(value: number): ConnectionBuilder<Built | 'port'> {
this.config.port = value
return this as any
}
database(value: string): ConnectionBuilder<Built | 'database'> {
this.config.database = value
return this as any
}
username(value: string): ConnectionBuilder<Built> {
this.config.username = value
return this as any
}
// build is only available when all required fields are set
build(this: ConnectionBuilder<RequiredFields>): Record<string, unknown> {
return this.config
}
}
// Usage
new ConnectionBuilder().host('localhost').port(5432).database('mydb').build() // OK
new ConnectionBuilder().host('localhost').port(5432).build() // Error! database is missing
Pattern 5: Const Assertions and Readonly Tuples
// Preserve literal types with as const
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = (typeof ROLES)[number] // 'admin' | 'editor' | 'viewer'
// Apply to configuration objects
const CONFIG = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
},
features: {
darkMode: true,
notifications: false,
},
} as const
type Config = typeof CONFIG
// All values are fixed as literal types
// Routing table
const routes = {
home: '/',
about: '/about',
users: '/users',
userDetail: '/users/:id',
} as const
type RoutePath = (typeof routes)[keyof typeof routes]
// '/' | '/about' | '/users' | '/users/:id'
Pattern 6: Variadic Tuple Types
// Combining tuple types
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B]
type AB = Concat<[1, 2], [3, 4]> // [1, 2, 3, 4]
// pipe function type
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B
function pipe<A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C): (a: A) => C
function pipe<A, B, C, D>(fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): (a: A) => D
function pipe(...fns: Function[]) {
return (x: any) => fns.reduce((acc, fn) => fn(acc), x)
}
const transform = pipe(
(x: string) => x.length,
(x: number) => x * 2,
(x: number) => x.toString()
)
const result = transform('hello') // '10'
Pattern 7: Type-safe ORM Queries
// Type-safe WHERE clause
type WhereClause<T> = {
[K in keyof T]?: T[K] | { eq: T[K] } | { ne: T[K] } | { gt: T[K] } | { lt: T[K] } | { in: T[K][] }
}
type OrderBy<T> = {
[K in keyof T]?: 'asc' | 'desc'
}
interface QueryOptions<T> {
where?: WhereClause<T>
orderBy?: OrderBy<T>
limit?: number
offset?: number
}
function findMany<T>(table: string, options: QueryOptions<T>): Promise<T[]> {
return Promise.resolve([])
}
// Usage
interface Product {
id: number
name: string
price: number
category: string
}
findMany<Product>('products', {
where: {
price: { gt: 100 },
category: { in: ['electronics', 'books'] },
},
orderBy: { price: 'desc' },
limit: 10,
})
Pattern 8: Recursive Types (JSON, Tree)
// JSON type
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
// Type-safe tree structure
interface TreeNode<T> {
value: T
children: TreeNode<T>[]
}
// Nested object path type
type NestedKeyOf<T extends object> = {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${NestedKeyOf<T[K]>}` : K
}[keyof T & string]
interface DeepObj {
user: {
profile: {
name: string
avatar: string
}
settings: {
theme: string
}
}
}
type Paths = NestedKeyOf<DeepObj>
// 'user' | 'user.profile' | 'user.profile.name' | 'user.profile.avatar' | 'user.settings' | 'user.settings.theme'
Pattern 9: HKT (Higher-Kinded Types) Emulation
TypeScript doesn't directly support higher-kinded types, but they can be emulated.
// HKT interface
interface HKT {
readonly type: unknown
}
// Functor interface emulation
interface Functor<F extends HKT> {
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>
}
// Kind type helper
type Kind<F extends HKT, A> = F extends { readonly type: unknown }
? (F & { readonly type: A })['type']
: never
// Array Functor
interface ArrayHKT extends HKT {
readonly type: Array<this['type']>
}
// Option Functor
type Option<A> = { tag: 'some'; value: A } | { tag: 'none' }
interface OptionHKT extends HKT {
readonly type: Option<this['type']>
}
Pattern 10: Module Augmentation and Declaration Merging
// Extend existing modules
declare module 'express' {
interface Request {
user?: {
id: string
role: string
}
requestId: string
}
}
// Interface merging (Declaration Merging)
interface Window {
__APP_CONFIG__: {
apiUrl: string
version: string
}
}
// Extend existing enums
enum Color {
Red = 'RED',
Blue = 'BLUE',
}
// Extension via namespace
namespace Color {
export function fromHex(hex: string): Color {
return Color.Red
}
}
Color.fromHex('#ff0000') // OK
7. Zod + tRPC: Runtime + Compile-time Type Safety
Zod Schemas for Runtime Validation + Type Inference
Zod uses a "schema-first" approach where a single schema provides both runtime validation and TypeScript types.
import { z } from 'zod'
// Schema definition = type definition + runtime validation
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(['admin', 'user', 'moderator']),
preferences: z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
language: z.string().optional(),
}),
createdAt: z.coerce.date(),
})
// Automatic type inference
type User = z.infer<typeof UserSchema>
// Runtime validation
const result = UserSchema.safeParse(unknownData)
if (result.success) {
console.log(result.data.name) // Type-safe
} else {
console.error(result.error.issues) // Detailed error info
}
Advanced Zod Patterns
// Discriminated unions
const EventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('click'),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal('keypress'),
key: z.string(),
modifiers: z.array(z.string()),
}),
z.object({
type: z.literal('scroll'),
deltaX: z.number(),
deltaY: z.number(),
}),
])
type AppEvent = z.infer<typeof EventSchema>
// Type transformation with transform
const DateFromString = z.string().transform((str) => new Date(str))
type ParsedDate = z.infer<typeof DateFromString> // Date
// Recursive schemas
type Category = z.infer<typeof CategorySchema>
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
)
tRPC: Full-stack Type-safe APIs
tRPC provides complete type safety between server and client without any code generation.
// Server router definition
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const appRouter = t.router({
user: t.router({
getById: t.procedure.input(z.object({ id: z.string().uuid() })).query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } })
return user
}),
create: t.procedure
.input(
z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.enum(['admin', 'user']),
})
)
.mutation(async ({ input }) => {
return await db.user.create({ data: input })
}),
list: t.procedure
.input(
z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
role: z.enum(['admin', 'user']).optional(),
})
)
.query(async ({ input }) => {
return await db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
where: input.role ? { role: input.role } : undefined,
})
}),
}),
})
export type AppRouter = typeof appRouter
React Query + tRPC Integration
// On the client — complete type safety
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
const trpc = createTRPCReact<AppRouter>();
function UserProfile({ userId }: { userId: string }) {
const userQuery = trpc.user.getById.useQuery({ id: userId });
if (userQuery.isLoading) return <div>Loading...</div>;
if (userQuery.error) return <div>Error occurred</div>;
// userQuery.data type is automatically inferred from server
return <div>{userQuery.data.name}</div>;
}
function CreateUserForm() {
const createUser = trpc.user.create.useMutation({
onSuccess: (data) => {
console.log(`User created: ${data.name}`);
},
});
const handleSubmit = (formData: FormData) => {
createUser.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
role: 'user',
});
};
return <form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.currentTarget)); }}>...</form>;
}
End-to-end Type Safety Architecture
// Full architecture flow:
// 1. Define Zod schemas (single source of truth)
// 2. Use schemas in tRPC router (server)
// 3. Auto-infer types on client (frontend)
// 4. Runtime validation handled automatically by Zod
// DB schema integration (Drizzle ORM example)
import { pgTable, varchar, integer, timestamp } from 'drizzle-orm/pg-core'
const users = pgTable('users', {
id: varchar('id', { length: 36 }).primaryKey(),
name: varchar('name', { length: 50 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
age: integer('age'),
createdAt: timestamp('created_at').defaultNow(),
})
// Infer types from Drizzle
type DbUser = typeof users.$inferSelect
type NewDbUser = typeof users.$inferInsert
// Sync Zod schema with DB types
const CreateUserInput = z.object({
name: z.string().min(2).max(50),
email: z.string().email().max(255),
age: z.number().int().min(0).optional(),
}) satisfies z.ZodType<Omit<NewDbUser, 'id' | 'createdAt'>>
8. TypeScript Performance Optimization
Optimal tsconfig.json Settings
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
}
}
Type Instantiation Depth Limits
Complex recursive types can hit TypeScript's type instantiation depth limit (default 50).
// Bad: infinite recursion risk
type DeepNested<T, Depth extends number[] = []> = Depth['length'] extends 10
? T
: { value: T; nested: DeepNested<T, [...Depth, 0]> }
// Good: limit recursion depth
type MaxDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
type SafeDeepPartial<T, D extends number = 5> = [D] extends [never]
? T
: T extends object
? { [K in keyof T]?: SafeDeepPartial<T[K], MaxDepth[D]> }
: T
Improving Build Speed with Project References
In large monorepos, Project References rebuild only changed projects.
{
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
],
"compilerOptions": {
"composite": true,
"incremental": true
}
}
Per-package tsconfig.json:
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
},
"references": [{ "path": "../shared" }]
}
Build commands:
# Build following project references
tsc --build
# Incremental build (only changed)
tsc --build --incremental
# Clean build
tsc --build --clean
SWC vs esbuild Transpiler Comparison
| Aspect | tsc | SWC | esbuild |
|---|---|---|---|
| Language | TypeScript | Rust | Go |
| Type checking | Yes | No | No |
| Speed (1000 files) | ~10s | ~0.5s | ~0.3s |
| Bundling | No | Yes (SWC Pack) | Yes |
| Decorators | Full support | Partial | Limited |
| Recommended for | CI type checking | Next.js, Jest | Vite, bundling |
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "esbuild src/index.ts --bundle --outdir=dist",
"dev": "tsx watch src/index.ts"
}
}
9. Type Gymnastics (Type Challenges) Practice
Type gymnastics means implementing logic using only TypeScript's type system. It's immensely helpful when dealing with complex types in practice.
Easy Level
// 1. MyPick implementation
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// Test
interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>
// { title: string; completed: boolean }
// 2. MyReadonly implementation
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
// 3. Tuple to Object
type TupleToObject<T extends readonly (string | number | symbol)[]> = {
[K in T[number]]: K
}
const tuple = ['tesla', 'model 3', 'model X'] as const
type TupleObj = TupleToObject<typeof tuple>
// 4. First of Array
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never
type A = First<[3, 2, 1]> // 3
type B = First<[]> // never
// 5. Length of Tuple
type Length<T extends readonly any[]> = T['length']
type L = Length<[1, 2, 3]> // 3
Medium Level
// 1. Deep Readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K]
}
// 2. Chainable Options
type Chainable<T = {}> = {
option<K extends string, V>(
key: K extends keyof T ? never : K,
value: V
): Chainable<T & Record<K, V>>
get(): T
}
declare const config: Chainable
const result = config
.option('foo', 123)
.option('name', 'hello')
.option('bar', { value: 'world' })
.get()
// 3. Trim
type TrimLeft<S extends string> = S extends `${' ' | '\n' | '\t'}${infer R}` ? TrimLeft<R> : S
type TrimRight<S extends string> = S extends `${infer R}${' ' | '\n' | '\t'}` ? TrimRight<R> : S
type Trim<S extends string> = TrimLeft<TrimRight<S>>
type Trimmed = Trim<' Hello World '> // 'Hello World'
// 4. Replace
type Replace<S extends string, From extends string, To extends string> = From extends ''
? S
: S extends `${infer L}${From}${infer R}`
? `${L}${To}${R}`
: S
type Replaced = Replace<'hello world', 'world', 'TypeScript'>
// 5. Flatten
type Flatten<T extends any[]> = T extends [infer First, ...infer Rest]
? First extends any[]
? [...Flatten<First>, ...Flatten<Rest>]
: [First, ...Flatten<Rest>]
: []
type Flat = Flatten<[1, [2, [3, [4]]]]> // [1, 2, 3, 4]
Hard Level
// 1. Curry (simplified version)
type Curry<F> = F extends (...args: infer A) => infer R
? A extends [infer First, ...infer Rest]
? (arg: First) => Rest extends [] ? R : Curry<(...args: Rest) => R>
: R
: never
declare function curry<F extends (...args: any[]) => any>(fn: F): Curry<F>
function add(a: number, b: number, c: number): number {
return a + b + c
}
const curriedAdd = curry(add)
const result2 = curriedAdd(1)(2)(3) // number
// 2. String Parser (simplified path parser)
type ParsePath<S extends string> = S extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ParsePath<Rest>
: S extends `${string}:${infer Param}`
? { [K in Param]: string }
: {}
type Params = ParsePath<'/users/:userId/posts/:postId'>
// { userId: string } & { postId: string }
// 3. IsUnion
type IsUnion<T, U = T> = T extends U ? ([U] extends [T] ? false : true) : never
type A1 = IsUnion<string | number> // true
type A2 = IsUnion<string> // false
Learning Roadmap and Recommended Resources
Beginner (1-2 weeks)
- Basic types, interfaces, generics fundamentals
- Union Types, Intersection Types
- Type Guards, Narrowing
Intermediate (2-4 weeks)
- Conditional Types, Mapped Types
- Template Literal Types
- Understanding built-in utility type internals
Advanced (4-8 weeks)
- Recursive types, Variadic Tuple Types
- Branded Types, Phantom Types
- HKT emulation
- type-challenges repository exercises
Master (ongoing)
- Compiler API usage
- Writing custom transformers
- Language Service Plugin development
10. Quiz
Q1: What is the main purpose of the NoInfer utility type?
NoInfer is a utility type that controls whether a specific parameter participates in type inference for generic functions. This prevents unintended type widening. For example, it is used when you want the default value parameter to not influence the inference of the generic type T.
Q2: How do you prevent distribution in Distributive Conditional Types?
To prevent union distribution in Conditional Types, wrap both sides of extends in brackets. For example, instead of T extends any ? T[] : never, write [T] extends [any] ? T[] : never. This ensures the union type is processed as a whole rather than being distributed individually.
Q3: Why are Branded Types necessary?
In TypeScript's structural typing system, types with the same structure are compatible. For example, if UserId and PostId are both strings, accidentally swapping them causes no compile error. Branded Types add a unique brand property to distinguish types that are structurally identical but logically different. This prevents incorrect ID usage at compile time.
Q4: What type safety advantages does tRPC provide compared to traditional REST APIs?
With tRPC, router types defined on the server are automatically inferred on the client. Without any code generation (codegen) process, server input/output types provide complete autocompletion and type checking on the client. When combined with Zod schemas, runtime validation is also handled automatically, achieving end-to-end type safety.
Q5: What are the main advantages of TypeScript Project References?
Project References optimize build performance in large monorepos. Key advantages include: First, incremental builds that only rebuild changed projects. Second, explicit dependency declaration between projects enabling automatic build order management. Third, when used with the composite option, declaration files are cached for each project, eliminating the need to re-parse source files when referenced by other projects.
11. References
- TypeScript Official Documentation — Handbook
- TypeScript 5.4 Release Notes
- TypeScript 5.5 Release Notes
- TypeScript 5.6 Release Notes
- TypeScript 5.7 Release Notes
- type-challenges GitHub
- Zod Official Documentation
- tRPC Official Documentation
- Total TypeScript — Matt Pocock
- TypeScript Deep Dive — Basarat
- Effect-TS — Type-safe Functional Programming
- Drizzle ORM — Type-safe ORM
- SWC Official Documentation
- esbuild Official Documentation
- TypeScript Performance Wiki
- Branded Types in TypeScript — Kent C. Dodds
- Why TypeScript's Type System is Turing Complete