Why Strict Mode Matters
TypeScript without strict mode is JavaScript with optional type annotations. TypeScript with strict mode is a different language that catches a whole class of bugs at compile time.
The gap is bigger than most developers realize. With strictNullChecks: false, null and undefined are subtypes of every type — meaning the type checker happily accepts code that will crash at runtime with "Cannot read properties of undefined." It's the single most common JavaScript runtime error, and it's preventable.
What "strict" Actually Enables
strict: true in tsconfig.json is a shorthand for enabling several flags:
{
"compilerOptions": {
// These are all enabled by "strict: true":
"strictNullChecks": true, // null/undefined are distinct types
"noImplicitAny": true, // Can't use any without being explicit
"strictFunctionTypes": true, // Function parameter types are checked contravariantly
"strictBindCallApply": true, // bind/call/apply have proper types
"strictPropertyInitialization": true, // Class properties must be initialized
"noImplicitThis": true, // 'this' must have an explicit type
"alwaysStrict": true, // Emits "use strict" in JS output
"useUnknownInCatchVariables": true // catch variables are 'unknown' not 'any'
}
}
Additionally valuable (not in strict bundle):
{
"compilerOptions": {
"noUncheckedIndexedAccess": true, // array[n] can be undefined
"exactOptionalPropertyTypes": true, // {a?: string} ≠ {a: string | undefined}
"noImplicitReturns": true, // All code paths must return
"noFallthroughCasesInSwitch": true, // No accidental switch fallthrough
"noImplicitOverride": true, // Class overrides need 'override' keyword
}
}
strictNullChecks: The Most Important One
// Without strictNullChecks:
function getLength(s: string) {
return s.length // Fine... but s could be null!
}
getLength(null) // TypeScript says ✅, runtime says ❌
// With strictNullChecks:
function getLength(s: string) {
return s.length
}
getLength(null) // ❌ TypeScript error: null is not assignable to string
// Now you're forced to handle the null case:
function getLength(s: string | null): number {
if (s === null) return 0
return s.length
}
// Or with optional chaining and nullish coalescing:
const length = s?.length ?? 0
Narrowing with strictNullChecks
interface User {
id: number
name: string
address?: { // Optional — might be undefined
city: string
country: string
}
}
function displayUser(user: User | null) {
// ❌ Without narrowing:
console.log(user.name) // Error: user is possibly null
console.log(user.address.city) // Error: user.address is possibly undefined
// ✅ With proper narrowing:
if (!user) return
console.log(user.name) // ✅ user is User here
// Address is still optional
console.log(user.address?.city) // ✅ Optional chaining: string | undefined
console.log(user.address?.city ?? 'Unknown') // ✅ Non-null: string
// Narrowing via destructuring:
const { address } = user
if (address) {
console.log(address.city) // ✅ address is { city: string, country: string } here
}
}
noImplicitAny: Stop Hiding Type Errors
// ❌ Without noImplicitAny — these compile without complaint:
function processData(data) { // data: any (implicit)
return data.someProperty.nested.value // No type checking!
}
// ✅ With noImplicitAny — forced to type everything:
function processData(data: ApiResponse) { // Must be explicit
return data.result.items[0].name // Type-checked!
}
// When you genuinely don't know the type (external data):
function processUnknown(data: unknown) {
// unknown forces you to narrow before using
if (typeof data === 'object' && data !== null && 'name' in data) {
console.log((data as { name: string }).name)
}
}
// For escape hatches, be explicit:
const value = JSON.parse(rawJson) as ApiResponse // Explicit cast
// Or use zod for runtime validation:
const result = ApiResponseSchema.parse(JSON.parse(rawJson))
strictPropertyInitialization
// ❌ Without strict — compiles but crashes:
class UserService {
db: Database // Declared but never assigned!
findUser(id: number) {
return this.db.query(id) // RuntimeError: cannot read 'query' of undefined
}
}
// ✅ With strict — three ways to fix:
// Option 1: Initialize in constructor
class UserService {
db: Database
constructor(db: Database) {
this.db = db // Assigned in constructor
}
}
// Option 2: Initialize inline
class UserService {
db: Database = new MockDatabase()
}
// Option 3: Definite assignment assertion (when you know it's initialized elsewhere)
class UserService {
db!: Database // '!' tells TypeScript "I know this is assigned, trust me"
initialize(db: Database) {
this.db = db
}
}
noUncheckedIndexedAccess: Array Safety
// Without noUncheckedIndexedAccess:
const arr = [1, 2, 3]
const first: number = arr[0] // TypeScript says number, but could be undefined!
// With noUncheckedIndexedAccess:
const arr = [1, 2, 3]
const first = arr[0] // Type: number | undefined
// Now you must handle the undefined case:
if (first !== undefined) {
console.log(first * 2)
}
// Or assert non-null (use sparingly):
const first = arr[0]! // Assert non-null
// Better: use safe access patterns
const first = arr.at(0) // Returns T | undefined — same as arr[0] with noUncheckedIndexedAccess
const sum = arr.reduce((acc, n) => acc + n, 0) // No index access needed
// Object index signatures:
interface Config {
[key: string]: string
}
const config: Config = { debug: 'true' }
const value = config['debug'] // With noUncheckedIndexedAccess: string | undefined
Migrating to Strict Mode Incrementally
Don't try to enable strict mode on a large existing codebase all at once — the errors will be overwhelming. Enable flags incrementally:
// tsconfig.json — start with the most impactful
{
"compilerOptions": {
// Phase 1: Start here (easiest wins)
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
// Phase 2: The big one
"strictNullChecks": true,
// Phase 3: Additional strictness
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"useUnknownInCatchVariables": true,
// Phase 4: Maximum strictness
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Technique: ts-ignore suppress with tracking comment
// When you can't fix an error immediately, mark it for later:
// @ts-expect-error TODO(2026-07-01): Fix after UserService refactor
const value = legacyUserService.getUnsafeData()
// ts-expect-error is better than ts-ignore:
// - ts-expect-error fails if the error disappears (forces you to remove it)
// - ts-ignore silently stays even when the error is fixed
Common Patterns After Enabling Strict
// Pattern 1: Type guards
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as any).id === 'number' &&
'name' in value &&
typeof (value as any).name === 'string'
)
}
// Pattern 2: Assertion functions
function assertDefined<T>(value: T | null | undefined, message?: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(message ?? 'Expected value to be defined')
}
}
const user = getUser(id) // User | null
assertDefined(user, 'User not found')
console.log(user.name) // ✅ TypeScript knows user is User here
// Pattern 3: Exhaustive switches
type Status = 'pending' | 'active' | 'inactive'
function displayStatus(status: Status): string {
switch (status) {
case 'pending': return 'Awaiting approval'
case 'active': return 'Active'
case 'inactive': return 'Inactive'
default:
// If you add a new Status value, this becomes a type error
const _exhaustive: never = status
throw new Error(`Unhandled status: ${_exhaustive}`)
}
}
// Pattern 4: Discriminated unions replace any
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number }
| { status: 'loading' }
function handleResult<T>(result: ApiResult<T>) {
switch (result.status) {
case 'success':
console.log(result.data) // ✅ TypeScript knows data exists here
break
case 'error':
console.log(result.error, result.code) // ✅ TypeScript knows error/code exist
break
case 'loading':
console.log('Loading...') // No data or error accessible
break
}
}
Strict mode TypeScript is one of the highest-ROI investments in a frontend codebase. The upfront cost of fixing type errors is real but bounded. The ongoing benefit — eliminating "Cannot read properties of undefined" in production — compounds indefinitely.
→ Analyze text content statistics (word count, character count) with the Text Statistics tool.