TypeScript can be the most expressive tool in your stack, or it can be a tangled mess of incomprehensible generics, nested conditionals, and type annotations that obscure more than they reveal. The difference is not the language — it is the patterns you choose.
At WEIRDSOFT, we maintain multiple large-scale Next.js applications with shared type libraries that span tens of thousands of lines. These are the patterns we reach for daily to keep our codebases readable, safe, and aggressively refactorable.
Discriminated Unions Over Enums
Traditional enums in TypeScript are a relic from the C# / Java era. They are nominal — two enums with identical values are not assignable to each other — and they generate runtime code, which affects bundle size. Discriminated unions are structural, composable, and vanish at compile time.
// ❌ Avoid: Runtime enum with nominal typing
enum Status {
Idle = 'idle',
Loading = 'loading',
Success = 'success',
Error = 'error',
}
function handleStatus(status: Status) {
switch (status) {
case Status.Idle: break
case Status.Loading: break
case Status.Success: break
case Status.Error: break
default: // No exhaustiveness check at compile time
}
}
// ✅ Prefer: Union type with exhaustiveness checking
type Status = 'idle' | 'loading' | 'success' | 'error'
function handleStatus(status: Status) {
switch (status) {
case 'idle': break
case 'loading': break
case 'success': break
case 'error': break
default:
// TypeScript errors here if any member of Status is unhandled
const exhaustive: never = status
}
}
Discriminated Unions for Complex State
When your state has multiple shapes, discriminated unions make illegal states unrepresentable:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; retryCount?: number }
function renderState<T>(state: RequestState<T>) {
switch (state.status) {
case 'idle':
return <EmptyState />
case 'loading':
return <LoadingSpinner />
case 'success':
// TypeScript knows `data` exists
return <DataView data={state.data} />
case 'error':
// TypeScript knows `error` exists
return <ErrorDisplay error={state.error} />
default:
const _exhaustive: never = state
throw new Error(`Unhandled state: ${state}`)
}
}
The beauty of this pattern is that adding a new state variant — say 'paused' — forces you to handle it everywhere. The compiler becomes your QA team.
Branded Types for Domain Primitives
Have you ever passed a userId to a function expecting a postId? String-based IDs are the most common source of type confusion in TypeScript codebases. Branded types solve this without runtime overhead.
// Define brands using intersection types
type UserId = string & { readonly __brand: 'UserId' }
type PostId = string & { readonly __brand: 'PostId' }
type OrderId = string & { readonly __brand: 'OrderId' }
// Factory functions — the single point of coercion
function UserId(id: string): UserId {
return id as UserId
}
function PostId(id: string): PostId {
return id as PostId
}
function OrderId(id: string): OrderId {
return id as OrderId
}
// Usage — compiler prevents mistakes
const userId = UserId('usr_abc123')
const postId = PostId('post_xyz789')
function getPostById(id: PostId): Promise<Post> { /* ... */ }
// ❌ Compile-time error: Argument of type 'UserId' is not assignable to parameter of type 'PostId'
getPostById(userId)
// ✅ Correct
getPostById(postId)
The Branding Trade-Off
Branded types provide compile-time safety at the cost of explicit factory functions at runtime boundaries (API responses, database calls, URL params). In our experience, the safety gain far outweighs the boilerplate for any codebase over 10,000 lines. For smaller projects, a naming convention may suffice.
Zod Integration for Runtime Safety
Branded types pair naturally with Zod for runtime validation:
import { z } from 'zod'
const UserIdSchema = z.string().brand<'UserId'>()
type UserId = z.infer<typeof UserIdSchema>
const userSchema = z.object({
id: UserIdSchema,
name: z.string(),
email: z.string().email(),
})
type User = z.infer<typeof userSchema>
// User.id is typed as branded UserId
Now your runtime validation feeds directly into your compile-time type safety. No casting, no escape hatches.
Generic Constraints with extends
Generic constraints are TypeScript's most underused safety feature. They prevent generic functions from accepting arguments that would break at runtime.
// ❌ Unsafe: Accepts any object, but the return is opaque
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// ✅ Safe: Constrained to objects with the specific key
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key])
}
const users: User[] = [/* ... */]
const names = pluck(users, 'name') // string[]
const emails = pluck(users, 'email') // string[]
// ❌ Compile-time error: 'nonexistent' is not a key of User
pluck(users, 'nonexistent')
Real-World Pattern: Repository with Generic Constraints
type Entity = { id: string; createdAt: Date }
class Repository<T extends Entity> {
private items = new Map<string, T>()
findById(id: T['id']): T | undefined {
return this.items.get(id)
}
findAll(): T[] {
return Array.from(this.items.values())
}
create(entity: Omit<T, 'id' | 'createdAt'> & { id?: string }): T {
const newEntity = {
...entity,
id: entity.id ?? crypto.randomUUID(),
createdAt: new Date(),
} as T
this.items.set(newEntity.id, newEntity)
return newEntity
}
update(id: T['id'], partial: Partial<Omit<T, 'id' | 'createdAt'>>): T | undefined {
const existing = this.items.get(id)
if (!existing) return undefined
const updated = { ...existing, ...partial }
this.items.set(id, updated)
return updated
}
}
// Usage — type-safe by construction
type User = { id: string; createdAt: Date; name: string; email: string }
const userRepo = new Repository<User>()
const user = userRepo.create({ name: 'Alice', email: '[email protected]' })
// user is typed as User
Conditional Types for Flexible APIs
Conditional types allow your types to adapt based on input. They are the foundation of type-safe builder patterns, form libraries, and API clients.
// A type-safe API response handler
type ApiResponse<T, E = Error> = T extends void
? { success: true }
: { success: true; data: T } | { success: false; error: E }
function createResponse<T, E = Error>(data: T, error?: E): ApiResponse<T, E> {
if (error) {
return { success: false, error } as ApiResponse<T, E>
}
return { success: true, data } as ApiResponse<T, E>
}
// Usage
const voidResponse = createResponse(undefined) // { success: true }
const dataResponse = createResponse({ id: 1 }) // { success: true; data: { id: number } }
const errorResponse = createResponse(undefined, new Error('fail')) // { success: false; error: Error }
Extract and Exclude in Practice
// Extract union members matching a pattern
type Actions = 'user:create' | 'user:delete' | 'post:create' | 'post:delete'
type UserActions = Extract<Actions, `user:${string}`>
// 'user:create' | 'user:delete'
// Exclude specific members
type NonDeleteActions = Exclude<Actions, `${string}:delete`>
// 'user:create' | 'post:create'
Template Literal Types for String Patterns
Template literal types let you encode string patterns into your type system. They are invaluable for event systems, route definitions, and namespaced actions.
// Type-safe event system
type CustomerEvents =
| `customer:${'created' | 'updated' | 'deleted'}`
| `customer:${string}:${'subscribe' | 'unsubscribe'}`
type OrderEvents =
| `order:${'placed' | 'shipped' | 'delivered' | 'cancelled'}`
| `order:${string}:refund`
type AppEvent = CustomerEvents | OrderEvents
type EventHandler<E extends AppEvent> = E extends `${string}:${infer Action}`
? (payload: { action: Action; timestamp: Date }) => void
: never
// Usage
function on<E extends AppEvent>(event: E, handler: EventHandler<E>): void {
// implementation
}
on('customer:created', (payload) => {
// payload.action is 'created'
// payload.timestamp is Date
})
Route Parameter Extraction
// Extract route parameters from a path pattern
type ExtractRouteParams<T extends string> =
T extends `${string}[${infer Param}]${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
: {}
type ProductRoute = '/products/[id]'
type ProductParams = ExtractRouteParams<ProductRoute>
// { id: string }
// Use with Next.js typed routes
type RouteParams<T extends string> = ExtractRouteParams<T>
function buildRoute<T extends string>(route: T, params: RouteParams<T>): string {
return Object.entries(params).reduce(
(path, [key, value]) => path.replace(`[${key}]`, encodeURIComponent(value)),
route
)
}
buildRoute('/products/[id]', { id: '123' }) // '/products/123'
Exhaustive Type Guards
Type guards narrow types at runtime while maintaining compile-time safety. The exhaustive guard pattern ensures you never forget to handle a case.
// Exhaustive check — the compiler enforces completeness
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${value}`)
}
// Combined with discriminated unions
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:
// If you add a new Shape variant without handling it here,
// TypeScript errors at this line
return assertNever(shape)
}
}
Custom Type Guards for Complex Validation
// Branded email type
type ValidEmail = string & { readonly __brand: 'ValidEmail' }
function isValidEmail(value: string): value is ValidEmail {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
// Usage in a validation pipeline
function validateUser(input: unknown) {
if (typeof input !== 'object' || input === null) {
throw new ValidationError('Input must be an object')
}
const { email } = input as Record<string, unknown>
if (typeof email !== 'string' || !isValidEmail(email)) {
throw new ValidationError('Invalid email format')
}
// email is typed as ValidEmail here
return { email }
}
Pattern Matching with Discriminated Unions
While TypeScript does not have native pattern matching (yet — TC39 proposal stage 1), you can approximate it with discriminated unions and a match utility:
type MatchResult<T, R> = {
[K in keyof T]: (value: Extract<T, { [P in K]: T[K] }>) => R
}
function match<T extends Record<string, unknown>, R>(
value: T,
patterns: { [K in keyof T]?: (value: Extract<T, Pick<T, K>>) => R }
): R | undefined {
const key = Object.keys(value).find((k) => k in patterns)
if (!key) return undefined
return patterns[key as keyof T]?.(value as any)
}
// Usage with API response states
type ApiResult<T> =
| { type: 'success'; data: T }
| { type: 'error'; error: Error; code: number }
| { type: 'loading' }
function handleResult<T>(result: ApiResult<T>) {
return match(result, {
success: ({ data }) => renderData(data),
error: ({ error, code }) => renderError(error, code),
loading: () => renderLoading(),
})
}
Strict Configuration Is Non-Negotiable
No pattern matters if your tsconfig.json is lenient. Every project at WEIRDSOFT starts with strict mode enabled and additional safety flags turned on:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true
}
}
| Setting | What it prevents |
|---|---|
strict |
Enables all strict family checks (noImplicitAny, strictNullChecks, etc.) |
noUncheckedIndexedAccess |
Every object access via [] returns T | undefined |
noImplicitReturns |
Every code path must return a value |
noFallthroughCasesInSwitch |
No accidental switch fallthrough |
exactOptionalPropertyTypes |
prop?: string means prop can be string | undefined, not just omitted |
forceConsistentCasingInFileNames |
Catches cross-platform case-sensitivity bugs |
Real-World Refactoring: From any to Type-Safe
We recently refactored a legacy analytics SDK for a client. The original code used any extensively:
// Before — 47 `any` usages in 300 lines
function track(event: string, properties: any) {
// ...
}
function identify(user: any) {
// ...
}
After applying the patterns above:
// After — zero `any` usages
type AnalyticsEvent =
| { name: 'page_view'; properties: { path: string; referrer?: string } }
| { name: 'click'; properties: { element: string; label: string } }
| { name: 'signup'; properties: { method: 'email' | 'google' | 'github' } }
| { name: 'purchase'; properties: { value: number; currency: string; items: number } }
function track<E extends AnalyticsEvent>(event: E['name'], properties: Extract<AnalyticsEvent, { name: E['name'] }>['properties']): void {
// implementation
}
// Usage — full autocomplete and type checking
track('page_view', { path: '/home' })
track('signup', { method: 'email' })
// ❌ Compile-time error: 'method' does not exist in page_view properties
track('page_view', { method: 'email' })
The result: zero production bugs in six months, improved developer experience (full autocomplete in every call site), and a 15% reduction in lines of code despite the added type safety.
The Cost of Clean Types
Clean TypeScript is not free. It requires discipline, consistent code review, and a team-wide commitment to type safety as a practice. The upfront cost is real — branded types require factory functions, discriminated unions require exhaustive switch statements, and conditional types require mental effort to read and write.
But the long-term cost of not doing it is far higher. Every any is a potential production bug. Every unhandled union variant is a runtime error waiting to happen. Every string-based ID is a future debugging session.
At WEIRDSOFT, we have found that the patterns above deliver the best ROI for production TypeScript. They catch bugs before they reach staging, make refactoring a mechanical process rather than a gamble, and let our team move fast without breaking things.