TypeScript Strict Mode: Patterns for Large Codebases
TypeScript strict mode eliminates a class of runtime bugs at compile time. Here are the patterns that make strict mode practical in large Next.js and Node.js codebases.
TypeScript strict mode catches bugs that would be silent runtime errors in non-strict code. Enabling it in a large codebase is painful without the right patterns. Here's what I use across my Next.js frontends and Node.js backends to make strict mode practical.
What strict mode enables
// tsconfig.json
{
"compilerOptions": {
"strict": true // enables all of:
// strictNullChecks: true
// strictFunctionTypes: true
// strictBindCallApply: true
// strictPropertyInitialization: true
// noImplicitAny: true
// noImplicitThis: true
// alwaysStrict: true
}
}
The most impactful: strictNullChecks. This forces you to handle undefined and null explicitly, eliminating the most common class of runtime errors.
Pattern 1: Exhaustive union handling
type Intent = "product_search" | "negotiate" | "greet" | "other"
function handleIntent(intent: Intent): Response {
switch (intent) {
case "product_search":
return searchProducts()
case "negotiate":
return startNegotiation()
case "greet":
return sendGreeting()
case "other":
return sendDefault()
// TypeScript error if any case is missing
}
}
// Exhaustiveness checker — fails at compile time if new union member is added
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`)
}
function handleIntentSafe(intent: Intent): Response {
switch (intent) {
case "product_search": return searchProducts()
case "negotiate": return startNegotiation()
case "greet": return sendGreeting()
case "other": return sendDefault()
default: return assertNever(intent) // TypeScript ensures this is unreachable
}
}
When you add a new Intent variant, TypeScript immediately errors at every switch statement that doesn't handle it. This is one of the highest-ROI patterns in TypeScript.
Pattern 2: Type-safe environment variables
// lib/env.ts
const env = {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
} as const
// Validate at startup (not at type level — runtime check)
function validateEnv(env: Record<string, string | undefined>): asserts env is Record<string, string> {
const missing = Object.entries(env)
.filter(([, v]) => v === undefined)
.map(([k]) => k)
if (missing.length > 0) {
throw new Error(`Missing environment variables: ${missing.join(", ")}`)
}
}
validateEnv(env)
export { env } // Now typed as Record<string, string>, never undefined
After validateEnv, TypeScript knows all env vars are strings (not string | undefined). No ! assertions needed elsewhere.
Pattern 3: Discriminated unions for API responses
type Success<T> = { ok: true; data: T }
type Failure = { ok: false; error: string; code: number }
type Result<T> = Success<T> | Failure
async function fetchUser(id: string): Promise<Result<User>> {
const resp = await fetch(`/api/users/${id}`)
if (!resp.ok) {
return { ok: false, error: "User not found", code: resp.status }
}
const data = await resp.json()
return { ok: true, data }
}
// Caller is forced to handle both cases
const result = await fetchUser("123")
if (result.ok) {
console.log(result.data.email) // TypeScript knows: data is User
} else {
console.error(result.error) // TypeScript knows: error is string
}
No try/catch spaghetti. No null returns. The type system enforces error handling at every call site.
Pattern 4: Branded types for domain safety
// Prevent mixing up IDs from different entities
type UserID = string & { readonly _brand: "UserID" }
type ProductID = string & { readonly _brand: "ProductID" }
function toUserID(raw: string): UserID {
return raw as UserID
}
function getUser(id: UserID): Promise<User> { /* ... */ }
function getProduct(id: ProductID): Promise<Product> { /* ... */ }
const userId = toUserID("user_123")
const productId = "prod_456" as ProductID
getUser(userId) // ✅ OK
getUser(productId) // ❌ TypeScript error: ProductID is not assignable to UserID
Branded types prevent passing userID to a function expecting productID — a real bug that happens in codebases with many entity types.
Pattern 5: Strict Next.js App Router
// app/[slug]/page.tsx — Next.js 15 App Router
// params is now a Promise in Next.js 15
interface PageProps {
params: Promise<{ slug: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export default async function Page({ params, searchParams }: PageProps) {
const { slug } = await params // must await
const { q } = await searchParams
const post = getPostBySlug(slug)
if (!post) {
notFound() // Next.js 404
}
// post is now narrowed: Post (not Post | undefined)
return <article>{post.title}</article>
}
notFound() is a TypeScript control flow assertion — after calling it, the remaining code knows post is defined.
Pattern 6: Zod for runtime validation at system boundaries
TypeScript types are compile-time only. For data crossing system boundaries (API responses, user input, environment variables), use Zod for runtime validation that produces typed output:
import { z } from "zod"
const PostSchema = z.object({
title: z.string().min(1).max(60),
description: z.string().min(140).max(160),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
tags: z.array(z.string()).min(1).max(6),
draft: z.boolean().default(false),
})
type Post = z.infer<typeof PostSchema> // TypeScript type from Zod schema
// Validate frontmatter from MDX files
function parsePost(raw: unknown): Post {
return PostSchema.parse(raw) // throws on invalid data, returns typed Post
}
Zod schema = runtime validation + TypeScript type in one. No duplicate type definitions.
tsconfig.json for production Next.js
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true, // array[i] is T | undefined
"noImplicitReturns": true, // all code paths return a value
"noFallthroughCasesInSwitch": true, // no switch fallthrough
"moduleResolution": "bundler",
"module": "ESNext",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
noUncheckedIndexedAccess: true is strict-mode-adjacent — arr[0] is typed as T | undefined, not T. Catches off-by-one and empty array bugs.
FAQ
Should I enable TypeScript strict mode on an existing codebase?
Yes, but gradually. Enable strict: true and use // @ts-nocheck on files that aren't ready, or use // @ts-ignore on specific errors. Fix them incrementally. The bug prevention is worth the migration effort.
What's the most impactful strict mode feature?
strictNullChecks — it forces explicit handling of null and undefined, eliminating the most common class of runtime crashes ("cannot read property of undefined").
Is Zod better than TypeScript types for validation? They serve different purposes. TypeScript types are compile-time only — they don't validate runtime data. Zod validates runtime data AND generates TypeScript types. Use both: Zod at system boundaries, TypeScript types throughout.
Does noUncheckedIndexedAccess break existing code?
Yes — arr[0] becomes T | undefined instead of T. Code that uses array indexing without null checks will error. It's worth enabling for new code; for existing code, evaluate the migration cost.
Written by Shihab Shahriar Antor — AI Engineer & Founder of Shahriar Labs. See also: Next.js 15 App Router: SEO & Performance Guide · Go Microservices: Patterns I Use in Production.