TypeScript Strict Mode Best Practices: Eliminating Runtime Errors Before They Happen

Adding "strict": true to your tsconfig.json is one of the highest-ROI things you can do on a TypeScript project. It activates eight compiler checks that collectively eliminate whole categories of runtime errors at compile time. But most teams either skip it entirely or enable it without understanding what each check does. This guide covers every strict flag in depth, with practical examples and a safe migration strategy for existing codebases.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · TypeScript · Angular · Spring Boot

TypeScript April 4, 2026 14 min read TypeScript Mastery Series
TypeScript strict mode eliminating runtime errors

The Cost of Permissive TypeScript

TypeScript Strict Mode Overview | mdsanwarhossain.me
TypeScript Strict Mode Checks — mdsanwarhossain.me

Without strict mode, TypeScript is fundamentally just JavaScript with optional type annotations. You can write code like this and the compiler says nothing:

// Permissive TypeScript — compiles with no errors
function processUser(user) {  // user is implicitly 'any'
  return user.profile.name.toUpperCase();  // crashes if user.profile is null
}

let name: string;
name = null;  // allowed without strictNullChecks

function logValue(value: string) { console.log(value.length); }
logValue(undefined as any);  // 'any' bypasses all checks

All of these produce runtime exceptions: Cannot read property 'name' of null, Cannot read property 'toUpperCase' of undefined. The type system gave you false confidence.

Strict mode eliminates this. It costs you some upfront effort when migrating an existing codebase, but it pays back in fewer production bugs, safer refactoring, and better developer tooling (autocomplete and hover types become accurate rather than misleading).

What "strict": true Actually Enables

The single "strict": true flag in tsconfig.json is a shorthand that enables eight individual flags simultaneously:

{
  "compilerOptions": {
    "strict": true,
    // Equivalent to enabling all of these:
    // "strictNullChecks": true,
    // "noImplicitAny": true,
    // "strictFunctionTypes": true,
    // "strictBindCallApply": true,
    // "strictPropertyInitialization": true,
    // "noImplicitThis": true,
    // "alwaysStrict": true,
    // "useUnknownInCatchVariables": true  (TypeScript 4.4+)
  }
}

You can also enable these individually for a gradual rollout. Let's examine each one.

strictNullChecks: The Most Impactful Flag

Without this flag, null and undefined are assignable to every type. With it, they form their own types that must be explicitly handled.

// strictNullChecks: false (default, dangerous)
let name: string = null;         // allowed
let count: number = undefined;   // allowed

// strictNullChecks: true (safe)
let name: string = null;         // Error: Type 'null' is not assignable to type 'string'
let name2: string | null = null; // OK — explicitly allows null

// Narrowing is required before use
function greet(name: string | null): string {
  // name.toUpperCase();  // Error: Object is possibly 'null'
  if (name === null) return 'Hello, Guest';
  return `Hello, ${name.toUpperCase()}`; // OK — narrowed to string
}

// Optional chaining + nullish coalescing are the idiomatic solutions
function getDisplayName(user: User | null): string {
  return user?.profile?.name ?? 'Anonymous';
}

noImplicitAny: No Hidden any

Without this flag, TypeScript silently assigns the any type to variables and parameters it cannot infer. This defeats the entire purpose of the type system.

// noImplicitAny: false
function processOrder(order) {       // order is silently 'any'
  return order.items.reduce(...);    // no type checking, runtime crash possible
}

// noImplicitAny: true
function processOrder(order) {       // Error: Parameter 'order' implicitly has 'any' type
  // ...
}

// Fix: always annotate parameters
function processOrder(order: Order): ProcessedOrder {
  return order.items.reduce((acc, item) => acc + item.price, 0);
}

// noImplicitAny also catches index access
const config: { [key: string]: string } = {};
const value = config['timeout'];  // string — OK
function lookup(key) {            // Error: Parameter 'key' implicitly has 'any' type
  return config[key];
}

// Fix
function lookup(key: string): string | undefined {
  return config[key];
}

strictFunctionTypes: Correct Function Parameter Variance

This flag enforces correct variance for function types — specifically, parameters are checked contravariantly rather than bivariantly. This prevents a subtle class of type errors in callbacks and event handlers.

type Logger = (message: string) => void;
type DetailedLogger = (message: string, level: 'info' | 'error') => void;

declare let logMessage: Logger;
declare let logDetailed: DetailedLogger;

// strictFunctionTypes: false (bivariant — both directions allowed, unsafe)
logMessage = logDetailed; // No error, but calling logMessage("hello") would pass
                          // undefined as 'level' to the DetailedLogger

// strictFunctionTypes: true (contravariant — only safe assignments)
logMessage = logDetailed; // Error: Type 'DetailedLogger' is not assignable to type 'Logger'
                          // Parameters of type 'DetailedLogger' are not compatible

// Why? Calling logMessage("hello") with no 'level' would break DetailedLogger.
// Contravariance protects against this.

// Safe: assign less specific to more specific (widening)
logDetailed = logMessage; // OK — Logger accepts any string call, which is safe

strictPropertyInitialization: No Uninitialized Class Fields

TypeScript Code Quality Patterns | mdsanwarhossain.me
TypeScript Code Quality Patterns — mdsanwarhossain.me

Class properties must be definitively assigned in the constructor, or TypeScript errors. This prevents the common JavaScript pattern of declaring fields and initializing them later (in a lifecycle hook, for example).

// strictPropertyInitialization: true
class UserService {
  private readonly repo: UserRepository; // Error: Property 'repo' has no initializer
                                         // and is not definitely assigned in the constructor

  constructor() {
    // forgot to initialize repo
  }
}

// Fix 1: Initialize in constructor
class UserService {
  private readonly repo: UserRepository;

  constructor(repo: UserRepository) {
    this.repo = repo; // definitely assigned
  }
}

// Fix 2: Use definite assignment assertion for frameworks that inject post-construction
class UserService {
  @Inject()
  private readonly repo!: UserRepository; // '!' tells TS: "I guarantee this is set"
                                           // Use sparingly — only when framework handles it
}

// Fix 3: Declare with undefined union
class UserService {
  private repo: UserRepository | undefined;

  initialize(repo: UserRepository): void {
    this.repo = repo;
  }

  findUser(id: string): Promise<User | null> {
    if (!this.repo) throw new Error('Service not initialized');
    return this.repo.findById(id);
  }
}

useUnknownInCatchVariables: Safer Error Handling

Before TypeScript 4.4, caught exceptions were always typed as any. This flag types them as unknown, forcing you to narrow before accessing properties.

// Before TypeScript 4.4 (or without useUnknownInCatchVariables)
try {
  await callExternalApi();
} catch (error) {
  console.error(error.message); // 'error' is 'any' — no type checking
  throw new Error(error.code);  // 'code' may not exist, silently undefined
}

// With useUnknownInCatchVariables: true (TypeScript 4.4+ / strict mode)
try {
  await callExternalApi();
} catch (error) {
  // error is 'unknown' — must narrow before use
  if (error instanceof Error) {
    console.error(error.message); // OK — narrowed to Error
    throw error;
  }
  if (typeof error === 'string') {
    throw new Error(error); // OK — narrowed to string
  }
  throw new Error('Unknown error occurred');
}

// Utility function for clean error handling
function toError(e: unknown): Error {
  if (e instanceof Error) return e;
  return new Error(String(e));
}

try {
  await riskyOperation();
} catch (e) {
  const error = toError(e);
  logger.error('Operation failed', { message: error.message, stack: error.stack });
}

strictBindCallApply and alwaysStrict

// strictBindCallApply: true — typed bind, call, apply
function greet(name: string, age: number): string {
  return `${name} is ${age}`;
}

greet.call(undefined, 'Alice', 30);        // OK
greet.call(undefined, 'Alice');            // Error: Expected 2 arguments, got 1
greet.call(undefined, 'Alice', '30');      // Error: Argument of type 'string' not assignable to 'number'

// Before strictBindCallApply, bind/call/apply accepted any arguments — no type safety at all

// alwaysStrict: true — emits "use strict" in every output file
// and parses files in strict mode. Prevents accidental use of
// deprecated JavaScript features (octal literals, with statement, etc.)

noImplicitThis: Typed this in Functions

// noImplicitThis: false
const handler = {
  name: 'Alice',
  greet() {
    setTimeout(function() {
      console.log(this.name); // 'this' is implicitly 'any' — runtime: undefined
    }, 100);
  }
};

// noImplicitThis: true — TypeScript errors on implicit 'this'
// Fix: use arrow functions (they capture 'this' lexically)
const handler = {
  name: 'Alice',
  greet() {
    setTimeout(() => {
      console.log(this.name); // OK — arrow function, 'this' is the outer handler object
    }, 100);
  }
};

// Fix: explicitly declare 'this' parameter type
function logContextName(this: { name: string }): void {
  console.log(this.name); // OK — 'this' is typed
}

Safe Migration Strategy for Existing Projects

Enabling "strict": true on a large existing project can produce hundreds or thousands of errors at once. Here is a proven incremental approach:

Phase 1: Enable strictNullChecks first

// tsconfig.json — Phase 1
{
  "compilerOptions": {
    "strictNullChecks": true  // Start with the highest-value flag
    // noImplicitAny, strict, etc. NOT yet enabled
  }
}

Fix all nullability errors. Patterns to use:

Phase 2: Enable noImplicitAny

// tsconfig.json — Phase 2
{
  "compilerOptions": {
    "strictNullChecks": true,
    "noImplicitAny": true
  }
}

Fix all parameters and variables that were implicitly any. Use // @ts-expect-error sparingly for third-party code you cannot yet type.

Phase 3: Enable full strict

// tsconfig.json — Phase 3
{
  "compilerOptions": {
    "strict": true  // Enables all remaining strict flags
  }
}

File-by-File Migration with ts-migrate

For very large codebases, the ts-migrate tool (by Airbnb) can automatically add // @ts-expect-error annotations to suppress errors, letting you commit strict mode as enabled and then fix errors gradually over time:

# Install ts-migrate
npm install -g @ts-morph/ts-migrate

# Migrate all files — adds @ts-expect-error to suppress existing errors
ts-migrate migrate ./src

# Now enable strict: true in tsconfig.json
# CI passes immediately; fix suppressed errors over time

Additional Strict-Like Flags Worth Enabling

Beyond the eight flags included in "strict": true, these additional flags are worth enabling in any serious TypeScript project:

Flag What it catches
noUncheckedIndexedAccessArray/object index access returns T | undefined, not T
noImplicitReturnsFunction branches that don't return a value
noFallthroughCasesInSwitchSwitch cases without break/return
exactOptionalPropertyTypesPrevents assigning undefined to optional props explicitly
noPropertyAccessFromIndexSignatureForces bracket notation for index signatures
forceConsistentCasingInFileNamesPrevents case-insensitive import bugs on macOS/Windows

Angular and NestJS specific considerations

Both Angular and NestJS already scaffold projects with "strict": true enabled by default. If you are working on an older Angular project that was created before this was the default:

// Angular migration — update tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitOverride": true,  // Angular 14+ enforces override keyword
    "noImplicitReturns": true
  }
}

// Common Angular strict-mode fixes:

// 1. Template expressions are also strict — use the safe navigation operator
// In templates: {{ user?.profile?.name }}

// 2. @Input() properties need definite assignment or ?
@Component({...})
class UserCardComponent {
  @Input() user!: User;        // '!' — guaranteed by Angular's input binding
  @Input() title?: string;     // Optional input — correct without '!'
}

// 3. Typed reactive forms (Angular 14+)
const form = new FormGroup({
  email: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
  age: new FormControl<number | null>(null),
});
// form.value.email is now 'string', not 'string | null | undefined'

Common Patterns for Handling null Safely

// 1. Null object pattern — eliminate null from a domain type
const GUEST_USER: User = { id: '', name: 'Guest', email: '', roles: [] };
function getUser(id: string | null): User {
  if (!id) return GUEST_USER;
  return userCache.get(id) ?? GUEST_USER;
}

// 2. Option type — explicit absence
type Option<T> = { some: true; value: T } | { some: false };

function findById(id: string): Option<User> {
  const user = db.find(id);
  if (!user) return { some: false };
  return { some: true, value: user };
}

const result = findById('123');
if (result.some) {
  console.log(result.value.name); // TypeScript knows 'value' exists here
}

// 3. Non-null assertion — use sparingly
function connectToDb(): DatabaseConnection {
  const conn = createConnection(config);
  if (!conn) throw new Error('Failed to connect');
  return conn!; // OK here — we just checked and threw
}

// 4. Type guard functions
function isNonNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const ids = ['1', null, '2', undefined, '3'];
const validIds = ids.filter(isNonNull); // string[] — nulls removed

Conclusion

TypeScript strict mode is not optional for production-grade code — it is the minimum bar. The eight strict flags together eliminate null pointer exceptions, accidental any propagation, unsafe function type assignments, and uninitialized class members. The upfront cost of fixing strict errors is a one-time investment; the ongoing benefit is a codebase where the type system is a genuine safety net rather than a documentation layer.

Start with strictNullChecks, then add noImplicitAny, and finally enable the full "strict": true. For new Angular and NestJS projects, strict mode is already the default — there is no reason not to use it. To go further with TypeScript, see the companion post on TypeScript Advanced Types: Generics, Conditional Types & Mapped Types.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · TypeScript · Angular · Spring Boot

Last updated: April 4, 2026