TypeScript Strict Mode Patterns: Getting the Most Out of the Type System
Advanced TypeScript patterns for strict mode — branded types, assertion functions, discriminated unions, exhaustive checks, and the patterns that eliminate runtime type errors for good.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Every TypeScript project I start gets "strict": true in the tsconfig before anything else. Not because I enjoy fighting the compiler, but because I have spent enough time debugging production issues that strict mode would have caught at build time. The types are there to work for you. Let them.
This post is a collection of the strict mode patterns I use most often in production codebases. These are not theoretical exercises — they are patterns I reach for repeatedly because they eliminate real categories of bugs.
Why Strict Mode Is Non-Negotiable
The strict flag in tsconfig.json is actually a bundle of several individual flags: strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, noImplicitThis, and strictPropertyInitialization. Together, they close the gaps where TypeScript would otherwise let unsafe code through silently.
Without strict mode, this compiles fine:
function getUser(id: string) {
// returns User | undefined from the database
return db.users.find(u => u.id === id)
}
// No error — but user might be undefined
const user = getUser("abc123")
console.log(user.name) // Runtime: Cannot read property 'name' of undefined
With strictNullChecks, the compiler forces you to handle the undefined case. That is not the compiler being annoying — that is the compiler telling you about a real bug.
I have worked in codebases where strict mode was turned off "to move faster." Every single one of them had runtime type errors in production that strict mode would have prevented. The time you save skipping the type check is borrowed from your future debugging sessions at 2 AM.
Pattern: Discriminated Unions for State Machines
This is the pattern I use most. Instead of modeling state as a bag of optional properties, model it as a union of explicit states:
// Before: optional property soup
interface Request {
status: string
data?: ResponseData
error?: Error
retryCount?: number
}
// After: discriminated union — each state is explicit
type Request =
| { status: "idle" }
| { status: "loading"; retryCount: number }
| { status: "success"; data: ResponseData }
| { status: "error"; error: Error; retryCount: number }
Now the compiler knows exactly which properties exist in each state. You cannot accidentally access data on a loading request or error on a success:
function handleRequest(req: Request) {
switch (req.status) {
case "idle":
return startRequest()
case "loading":
return showSpinner(req.retryCount)
case "success":
return renderData(req.data) // data is guaranteed to exist here
case "error":
return showError(req.error) // error is guaranteed to exist here
}
}
I use this pattern for anything that has distinct states: authentication flows, form submissions, WebSocket connections, payment processing. If you are reaching for optional properties to model "sometimes this exists," stop and ask whether you actually have a union of distinct states.
Pattern: Branded Types for Type-Safe IDs
This one catches a class of bugs that most teams do not even realize they have. When every ID in your system is a string, nothing stops you from passing a user ID where an order ID is expected:
// Both are strings — the compiler cannot tell them apart
function getOrder(orderId: string) { ... }
function getUser(userId: string) { ... }
const userId = "user_abc123"
getOrder(userId) // No error! But this is definitely a bug.
Branded types fix this by adding a phantom property that exists only at the type level:
type Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">
function getOrder(orderId: OrderId) { ... }
function getUser(userId: UserId) { ... }
// Constructor functions that validate and brand
function toUserId(id: string): UserId {
if (!id.startsWith("user_")) throw new Error("Invalid user ID")
return id as UserId
}
function toOrderId(id: string): OrderId {
if (!id.startsWith("order_")) throw new Error("Invalid order ID")
return id as OrderId
}
const userId = toUserId("user_abc123")
getOrder(userId) // Compile error! Argument of type 'UserId' is not assignable to 'OrderId'
I use this pattern extensively in REST API codebases where route handlers accept multiple ID parameters. The compiler catches the mix-up before it becomes a data corruption issue.
Pattern: Assertion Functions for Runtime Validation
Assertion functions bridge the gap between runtime validation and compile-time type narrowing. They tell TypeScript "if this function returns without throwing, the value is this type":
function assertDefined<T>(
value: T | null | undefined,
message: string
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(message)
}
}
function assertValidEmail(
value: string
): asserts value is Brand<string, "Email"> {
if (!value.includes("@") || value.length < 3) {
throw new Error(`Invalid email: ${value}`)
}
}
// Usage
const user = await db.users.findUnique({ where: { id } })
assertDefined(user, `User not found: ${id}`)
// TypeScript now knows user is User, not User | null
assertValidEmail(input.email)
// TypeScript now knows input.email is a branded Email type
The asserts return type is the key. Without it, TypeScript does not understand that the function narrows the type. This is especially powerful at the boundaries of your application — request handlers, message consumers, configuration loaders — where data comes in untyped and needs to be validated before the rest of your code touches it.
Pattern: Exhaustive Checks With never
When you switch over a discriminated union, you want the compiler to tell you if you miss a case. The never type makes this possible:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
}
type PaymentStatus = "pending" | "processing" | "completed" | "failed" | "refunded"
function getStatusMessage(status: PaymentStatus): string {
switch (status) {
case "pending": return "Awaiting payment"
case "processing": return "Processing your payment"
case "completed": return "Payment complete"
case "failed": return "Payment failed"
case "refunded": return "Payment refunded"
default: return assertNever(status)
}
}
If someone adds a new status to PaymentStatus — say "disputed" — the assertNever call will immediately produce a compile error because "disputed" is not assignable to never. This turns a runtime oversight into a compile-time enforcement. I have seen this pattern prevent real bugs in codebases with dozens of status types that change over time. It is a must-have in any enterprise codebase where multiple teams contribute to shared type definitions.
Pattern: Template Literal Types for String Validation
Template literal types let you enforce string formats at the type level:
type HexColor = `#${string}`
type ApiRoute = `/api/${string}`
type EventName = `${string}:${string}`
type SemVer = `${number}.${number}.${number}`
function setColor(color: HexColor) { ... }
setColor("#ff0000") // Works
setColor("red") // Compile error
function registerRoute(route: ApiRoute) { ... }
registerRoute("/api/users") // Works
registerRoute("/users") // Compile error
// Combine with unions for tighter constraints
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
type RouteKey = `${HttpMethod} /api/${string}`
const routes: Record<RouteKey, Function> = {
"GET /api/users": listUsers,
"POST /api/users": createUser,
"YEET /api/users": deleteAll, // Compile error — "YEET" is not an HttpMethod
}
These are not as powerful as full regex validation, but they catch a surprising number of formatting mistakes at compile time. I find them most useful for configuration objects and routing tables where the string format matters.
The unknown vs any Discipline
Here is my hard line: any should never appear in production code. Every any is a hole in the type system. It is not just untyped — it actively infects everything it touches. Assign an any to a typed variable and the type checker goes silent. It is a contagion.
unknown is the correct replacement. Both accept any value, but unknown requires you to narrow the type before you can use it:
// any: the compiler gives up entirely
function processAny(input: any) {
input.foo.bar.baz() // No error. No safety. Good luck at runtime.
}
// unknown: the compiler requires you to check first
function processUnknown(input: unknown) {
input.foo // Compile error! Object is of type 'unknown'
// You must narrow first
if (typeof input === "object" && input !== null && "foo" in input) {
// Now TypeScript knows input has a 'foo' property
}
}
Use unknown for external data: API responses, user input, parsed JSON, deserialized messages. Then validate and narrow. This is where assertion functions earn their keep — validate at the boundary, enjoy type safety everywhere else.
When I review code, the presence of any is one of the first things I look for. A codebase with fifty any annotations is a codebase with fifty places where the type system has been asked to look away. Every one of them is a potential runtime error hiding behind a false sense of safety.
Setting Up Strict Mode for New and Existing Projects
For new projects, this is the baseline tsconfig I start with:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
noUncheckedIndexedAccess is the one most people miss. Without it, accessing an array element or object property by index returns the element type directly, even though it might be undefined:
const items: string[] = []
const first = items[0] // Without the flag: string. With the flag: string | undefined.
For existing projects that have been running without strict mode, do not try to flip the switch all at once. Here is the migration path I use:
- Enable
strictin tsconfig. - Add
// @ts-expect-erroror// @ts-ignoreto suppress existing errors (track the count). - Commit that as your baseline.
- Set a team rule: no new suppressions. Every new file and every modified function gets fully strict types.
- Chip away at the existing suppressions during refactoring and bug fixes.
This lets you get the benefits of strict mode for new code immediately while migrating the existing code incrementally. I have used this approach on codebases with thousands of files and it works.
The Bottom Line
TypeScript's type system is remarkably powerful, but you have to opt in to that power. Strict mode is the foundation. Discriminated unions, branded types, assertion functions, exhaustive checks, and template literal types are the patterns that make strict mode practical and productive.
The compiler is not your enemy. It is the cheapest, fastest QA engineer you will ever have. Let it do its job.