Back to articles

TypeScript Strict Mode: Best Practices for Production Code

Configure and Master Strict Mode Settings to Prevent Bugs Before They Happen

June 15, 2021
Updated March 8, 2026
TypeScript strict mode compiler configuration and type safety visualization
typescript
frontend
best practices
web development
9 min read
note

Updated March 2026: Originally published in 2021; updated to reflect current TypeScript practices and ESLint integration patterns.

Also, pretty banner because we have AI-generated images now.

Previous in the series: Mastering TypeScript Utility Types: Advanced Patterns in 2026

You push code to production. It compiles. Tests pass. Then in production, you get: Cannot read property 'x' of undefined.

The code looked fine. You typed it. The compiler signed off. But your types weren’t actually catching anything.

This is what happens when strict mode isn’t, well.. strict.

When TypeScript was first released, the default was loose. You could opt-in to type safety gradually. The idea was good: lower the barrier to entry.

The reality? It created a false sense of security. Developers wrote TypeScript that felt safe but shipped bugs anyway. I am one of those developers. Or used to be.

Strict mode changed this. It’s not a single switch rather it’s a group of interconnected compiler options that force you to be explicit about types, handle null/undefined intentionally, and surface issues before they reach production.

It’s the difference between TypeScript that catches bugs and TypeScript that merely compiles.

Why Strict Mode Matters

Let me illustrate the difference with a real example (or the best I could come up with):

Without Strict Mode

// tsconfig.json with loose defaults
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext"
    // strict: false (implicit)
  }
}

// Your code
interface User {
  id: number;
  name: string;
  email: string;
}

function getUser(userId: number) {
  const user = findUserInDatabase(userId); // Could be undefined!
  return user.name.toUpperCase(); // compiles fine in loose mode
}

// Runtime: Cannot read property 'name' of undefined

With Strict Mode

// tsconfig.json
{
  "compilerOptions": {
    "strict": true // Enables ALL strict checks
  }
}

// Your code
function getUser(userId: number): string {
  const user = findUserInDatabase(userId); // Could be undefined
  return user.name.toUpperCase(); // error: Object is possibly 'undefined'
}

// Forced to handle the case:
function getUser(userId: number): string | null {
  const user = findUserInDatabase(userId);
  if (!user) return null; // Handle undefined explicitly
  return user.name.toUpperCase(); // safe now
}

Setting Up Strict Mode

The Easy Way: Enable Strict

{
  "compilerOptions": {
    "strict": true
  }
}

This single setting enables:

  • noImplicitAny
  • noImplicitThis
  • strictBindCallApply
  • strictNullChecks
  • strictFunctionTypes
  • strictPropertyInitialization
  • alwaysStrict
  • useUnknownInCatchVariables (TS 4.4+)
  • strictBuiltinIteratorReturn (TS 5.6+)
quote

I’m sorry if the list doesn’t cover it all, I had to google the exact names and the list didn’t feel that important after the fact.

The Nuanced Way: Fine-Tuned Configuration

If you’re working with an existing codebase with loose type safety, you might want to enable strict checks incrementally:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "ESNext",
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "moduleResolution": "bundler",
    "allowJs": true,
    "noEmit": true
  }
}
important

If migrating an existing codebase, enable strict checks one at a time, fix errors, then move to the next. It’s less overwhelming than fixing everything at once.

tip

I’ve migrated more than a couple of codebases to strict mode. The trick isn’t forcing it all at once. It’s making it part of your daily grind. Pick the module you’re touching anyway, fix it, move on. Two weeks in, you’ll have covered your core business logic with barely any disruption.

Well, if the TypeScript gods are in your favor, that is.

Understanding Each Strict Setting

1. noImplicitAny

This prevents TypeScript from falling back to any when it can’t infer a type. It’s the #1 setting that catches real bugs.

Building on this foundation, you can use TypeScript’s utility types and generics to create reusable, type-safe abstractions that eliminate the need for any.

// Without noImplicitAny
function add(a, b) { // 'a' and 'b' are implicitly 'any'
  return a + b; // Could be numbers, strings, objects, or anything!
}

// With noImplicitAny (you must be explicit)
function add(a: number, b: number): number {
  return a + b;
}

// Callbacks and higher-order functions
const users = []; // implicitly typed as any[]

// Error: Parameter 'user' implicitly has an 'any' type
users.forEach(user => {
  console.log(user.name);
});

// Fixed with explicit array type
const typedUsers: User[] = [];
typedUsers.forEach(user => {
  console.log(user.name); // TypeScript infers 'user' is User from User[]
});

2. strictNullChecks

This is the game-changer. Without it, null and undefined can be assigned to any type. With it, you must explicitly handle them.

With strictNullChecks enabled, you’ll naturally develop practices like type narrowing and type guards which you can think of as defensive techniques that become invaluable as your codebase grows.

// Without strictNullChecks
interface User {
  id: number;
  name: string;
  email?: string; // optional, but not nullable
}

const user: User = { id: 1, name: "Alice" };
const emailLength = user.email.length; // compiles (but crashes at runtime!)

// Runtime error: Cannot read property 'length' of undefined

// With strictNullChecks
const emailLength = user.email?.length; // Use optional chaining
// or
const emailLength = user.email ? user.email.length : 0; // Handle explicitly

Real-world API example:

interface ApiResponse {
  status: number;
  data: unknown;
}

// Unsafe without strictNullChecks
function handleResponse(response: ApiResponse) {
  console.log(response.data.message); // Might be null/undefined!
}

// With strictNullChecks
function handleResponse(response: ApiResponse | null) {
  if (!response) return; // Must check
  
  if (response.data && typeof response.data === "object" && "message" in response.data) {
    console.log(response.data.message);
  }
}

3. strictFunctionTypes

This enforces contravariance on function parameter types. It’s nuanced but catches subtle bugs with callbacks.

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Without strictFunctionTypes, this would be allowed:
const dogHandler = (dog: Dog) => console.log(dog.breed);
const animalHandler: (animal: Animal) => void = dogHandler; // Error with strictFunctionTypes

// This is a problem because:
const animals: Animal[] = [{ name: "Cat" }];
animals.forEach(animalHandler); // Crashes! No breed property on cat

4. strictPropertyInitialization

Forces you to initialize class properties properly. No more silent undefined properties.

// Without strictPropertyInitialization
class User {
  id: number;
  name: string;
  
  constructor(id: number) {
    this.id = id;
    // 'name' is never set, but TypeScript doesn't complain
  }
}

// With strictPropertyInitialization
class User {
  id: number;
  name: string; // error: Property 'name' has no initializer
  
  // Option 1: Initialize in declaration
  status: string = "active";
  
  // Option 2: Initialize in constructor
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
  
  // Option 3: Mark as optional
  nickname?: string;
  
  // Option 4: Definite assignment assertion (use sparingly!)
  temporaryValue!: string;
}

5. noImplicitThis

Prevents this from being implicitly any in certain contexts.

// Without noImplicitThis
function processOrder() {
  if (this.user) { // 'this' is implicitly any
    this.user.notified = true;
  }
}

// With noImplicitThis (be explicit)
function processOrder(this: OrderProcessor) {
  if (this.user) {
    this.user.notified = true;
  }
}

// In classes, this is automatic
class OrderProcessor {
  user?: User;
  
  processOrder() {
    if (this.user) { // 'this' is correctly typed as OrderProcessor
      this.user.notified = true;
    }
  }
}

6. useUnknownInCatchVariables (TS 4.4+)

Catch clause variables are typed as unknown instead of implicit any.

// Without useUnknownInCatchVariables
try {
  something();
} catch (error) {
  console.log(error.message); // 'error' is implicitly any
}

// With useUnknownInCatchVariables
try {
  something();
} catch (error) {
  // 'error' is unknown - you must narrow it
  if (error instanceof Error) {
    console.log(error.message); // safe
  } else if (typeof error === "string") {
    console.log(error);
  } else {
    console.log("Unknown error");
  }
}

Real-World: Migrating to Strict Mode

If you’re adding strict mode to an existing project, here’s a practical approach:

{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "allowJs": false,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Then, if you have hundreds of errors and need to move faster:

# Fix auto-fixable issues
tsc --noEmit --strict 2>&1 | head -100 # See first batch of errors

Create a list of files with errors and tackle them module by module.

Progressive Strictness Pattern

// For legacy code, use a type assertion as a temporary escape hatch
const user = findUser(userId) as User; // Should be temporary!

// But document it and fix it later:
const user = findUser(userId) as User; // TODO: handle null case in migration

Strict Mode + Linting

Combine strict mode with ESLint rules for maximum safety:

{
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended-type-checked"],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-non-null-assertion": "warn",
    "@typescript-eslint/explicit-function-return-types": "warn",
    "@typescript-eslint/no-floating-promises": "error"
  }
}

Performance Impact

Good news: strict mode has minimal compilation performance impact. The extra type checking is usually negligible on modern machines.

What does slow things down:

  • Very large union types
  • Deeply nested generics
  • Complex conditional types

If you notice slow compilation with strict mode, profile your types before blaming strict mode.

Common Strict Mode Gotchas

Gotcha #1: Optional vs Nullable

// These are different!
interface Config {
  timeout?: number; // optional - can be omitted, but if present, must be number
}

interface Config {
  timeout: number | null; // nullable - must exist, but can be null
}

const config: Config = {}; // if timeout is required (second example)
const config: Config = { timeout: null }; // okay (second example)

Gotcha #2: Array Element Defaults

// This is tricky:
interface User {
  roles: Role[]; // with strict mode, you must initialize this
}

class UserManager {
  user: User = {
    roles: [] // must provide empty array, not just omit
  };
}

Gotcha #3: Strict Mode Doesn’t Catch Everything

// This compiles fine even with strict mode!
async function loadData() {
  getData(); // Floating promise - not awaited, but TypeScript won't warn
}

// Use ESLint's @typescript-eslint/no-floating-promises to catch this:
async function loadData() {
  await getData(); // Properly awaited
}
note

Strict mode focuses on type safety, not runtime behavior. For catching unawaited promises and similar issues, pair strict mode with ESLint’s @typescript-eslint/recommended-type-checked rules.

When to Consider Loosening Strict Mode

There are few cases, but they exist:

  • Legacy codebases: Gradual migration is better than a rewrite
  • Library integration: Some JS libraries have poor types
  • Prototyping: Sometimes you need to move fast temporarily
  • Specific flags only: Disable just noImplicitAny or strictNullChecks if needed, not all of strict
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": false // Only relax this one
  }
}

Wrapping up

  1. Enable strict mode by default - it catches real bugs before they reach users
  2. strictNullChecks is non-negotiable - probably the most important setting
  3. Use optional chaining (?.) and nullish coalescing (??) to work with strict mode elegantly
  4. Migrate gradually if you have existing code - fixing one file at a time is sustainable
  5. Combine with ESLint for defense-in-depth type safety
  6. Document escape hatches like as assertions so they can be fixed later

Strict mode isn’t about being strict for strictness’s sake. It’s about preventing bugs that would otherwise cost you hours of debugging in production. The upfront investment in configuration and type annotations pays for itself the first time it catches a null reference that would have shipped.

Start with strict: true if you’re building something new. If you’re working with an existing codebase, enable it incrementally—pick one module at a time, fix the errors, move on. It feels tedious for a day or two. Then it becomes invisible. And then you realize you almost never ship null reference bugs anymore.

In the next part of this series, we’ll explore type guards and narrowing—the techniques that make working with strict mode not just bearable, but genuinely enjoyable.


Previous in the series: Mastering TypeScript Utility Types: Advanced Patterns in 2026

Next in the series: TypeScript Type Guards and Narrowing: Writing Type-Safe Conditionals

Continue Reading

Discover more insights and stories that you might be interested in.