Beyond Basic TypeScript
Most TypeScript guides cover interfaces, generics, and union types. This guide covers features that separate a TypeScript expert from a beginner: the type-level programming constructs that let you express complex invariants at compile time, eliminating entire categories of runtime bugs.
Conditional Types
Conditional types let the TypeScript type system make decisions:
// Basic form: T extends U ? TrueType : FalseType
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Extract the element type from an array
type ElementType<T> = T extends (infer E)[] ? E : never
type StrElem = ElementType<string[]> // string
type NumElem = ElementType<number[]> // number
type NotArray = ElementType<string> // never
Distributive Conditional Types
When you apply a conditional type to a union, it distributes over each member:
type ToArray<T> = T extends any ? T[] : never
// Distributes: (string extends any ? string[] : never) | (number extends any ? number[] : never)
type Result = ToArray<string | number> // string[] | number[]
// Prevent distribution by wrapping in a tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type Combined = ToArrayNonDist<string | number> // (string | number)[]
The infer Keyword
infer captures a type within a conditional type:
// Extract return type (TypeScript has ReturnType<T> built-in)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
// Extract what a Promise resolves to
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
type UserData = Awaited<Promise<{ id: string; name: string }>>
// { id: string; name: string }
// Extract first parameter
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never
type EmailParam = FirstParam<(email: string, verified: boolean) => void> // string
// Deep extraction from nested structures
type UnpackNested<T> = T extends { data: { items: (infer I)[] } } ? I : never
type Item = UnpackNested<{ data: { items: User[] } }> // User
Template Literal Types
Template literal types generate string types programmatically:
// Basic combination
type Greeting = `Hello, ${string}!`
const g1: Greeting = 'Hello, World!' // OK
const g2: Greeting = 'Hi, World!' // Error
// Combining unions — cartesian product
type Color = 'red' | 'green' | 'blue'
type Shade = 'light' | 'dark'
type ColorVariant = `${Shade}-${Color}`
// "light-red" | "light-green" | ... | "dark-blue"
// CSS property names
type CSSProperty = 'margin' | 'padding' | 'border'
type CSSDirection = 'top' | 'right' | 'bottom' | 'left'
type DirectionalCSS = `${CSSProperty}-${CSSDirection}`
Type-Safe Event System
type EventName = 'user' | 'post' | 'comment'
type EventAction = 'created' | 'updated' | 'deleted'
type AppEvent = `${EventName}:${EventAction}`
class TypedEmitter {
on(event: AppEvent, handler: (data: unknown) => void): void { ... }
emit(event: AppEvent, data: unknown): void { ... }
}
const emitter = new TypedEmitter()
emitter.on('user:created', handler) // OK
emitter.on('user:removed', handler) // Error: 'removed' not in EventAction
String Manipulation Types
type DataKeys = 'firstName' | 'lastName' | 'email'
type GetterNames = `get${Capitalize<DataKeys>}`
// "getFirstName" | "getLastName" | "getEmail"
// Derived getter type from data object
type Getters<T extends Record<string, unknown>> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type UserGetters = Getters<{ name: string; age: number }>
// { getName: () => string; getAge: () => number }
Branded Types: Nominal Typing
TypeScript uses structural typing — two types with the same shape are interchangeable. Branded types achieve nominal typing:
// Without branded types — easy to mix up IDs
type UserId = string
type ProductId = string
function getUser(id: UserId): Promise<User> { ... }
function getProduct(id: ProductId): Promise<Product> { ... }
// TypeScript allows this — both are just string
const productId: ProductId = 'prod-123'
await getUser(productId) // No type error, but wrong!
// With branded types
declare const __brand: unique symbol
type Brand<T, B> = T & { [__brand]: B }
type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
// Constructor functions enforce the brand
function toUserId(id: string): UserId {
// Validate format here
if (!id.startsWith('user-')) throw new Error('Invalid UserId')
return id as UserId
}
const userId = toUserId('user-123')
const productId = 'prod-123' as ProductId
await getUser(productId) // Type error! Cannot assign ProductId to UserId
await getUser(userId) // OK
Branded types are used extensively in financial systems where mixing currency amounts is catastrophic:
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD
}
const price: USD = 100 as USD
const euros: EUR = 85 as EUR
addUSD(price, euros) // Type error — cannot add EUR to USD
Mapped Types
Mapped types transform existing types:
// Built-in utilities implemented with mapped types
type Readonly<T> = { readonly [K in keyof T]: T[K] }
type Partial<T> = { [K in keyof T]?: T[K] }
type Required<T> = { [K in keyof T]-?: T[K] }
// Make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type User = { id: string; name: string; email: string; bio: string }
type UserUpdate = PartialBy<User, 'bio' | 'email'>
// { id: string; name: string; bio?: string; email?: string }
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
// Filter keys by value type
type KeepStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K]
}
type UserStrings = KeepStrings<{ id: string; age: number; name: string }>
// { id: string; name: string }
Type Guards
Type guards narrow types at runtime with full type safety:
// User-defined type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'email' in value &&
typeof (value as any).id === 'string' &&
typeof (value as any).email === 'string'
)
}
// Discriminated union type guard
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rectangle'; width: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2
case 'square': return shape.side ** 2
case 'rectangle': return shape.width * shape.height
// TypeScript ensures exhaustiveness — adding a new Shape variant
// without handling it here becomes a compile error
}
}
// Assertion function
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`)
}
}
function processInput(value: unknown) {
assertIsString(value)
// value is narrowed to string here
return value.toUpperCase()
}
Putting It Together: A Type-Safe API Client
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
type ApiEndpoints = {
'GET /users': { response: User[] }
'GET /users/:id': { params: { id: UserId }; response: User }
'POST /users': { body: CreateUserDTO; response: User }
'PATCH /users/:id': { params: { id: UserId }; body: UpdateUserDTO; response: User }
'DELETE /users/:id': { params: { id: UserId }; response: void }
}
type EndpointKey = keyof ApiEndpoints
// Extract parts of the key
type ExtractMethod<K extends EndpointKey> = K extends `${infer M} ${string}` ? M : never
type ExtractPath<K extends EndpointKey> = K extends `${string} ${infer P}` ? P : never
// Type-safe fetch function
async function apiFetch<K extends EndpointKey>(
endpoint: K,
options: Omit<ApiEndpoints[K], 'response'>
): Promise<ApiEndpoints[K]['response']> {
// Implementation
const [method, path] = (endpoint as string).split(' ')
const response = await fetch(buildUrl(path, (options as any).params), {
method,
body: (options as any).body ? JSON.stringify((options as any).body) : undefined,
})
return response.json()
}
// Usage: fully type-safe
const users = await apiFetch('GET /users', {})
// users is User[]
const user = await apiFetch('GET /users/:id', { params: { id: userId } })
// user is User — and id must be UserId branded type
Advanced TypeScript is a force multiplier. Each hour spent writing precise types saves hours of debugging runtime errors. Start with branded types for your domain identifiers and conditional types for your utility functions — you will wonder how you shipped without them.