Home JavaScript TypeScript Strict Mode Explained: What It Does and Why You Need It

TypeScript Strict Mode Explained: What It Does and Why You Need It

In Plain English 🔥
Imagine you're writing a letter and you hire two proofreaders. The first one only flags spelling mistakes. The second one flags spelling, grammar, missing words, ambiguous sentences, AND tells you when something you wrote could be misunderstood. TypeScript's strict mode is that second proofreader — it doesn't just check the basics, it checks everything that could quietly cause a problem later. Without it, TypeScript is polite. With it, TypeScript is honest.
⚡ Quick Answer
Imagine you're writing a letter and you hire two proofreaders. The first one only flags spelling mistakes. The second one flags spelling, grammar, missing words, ambiguous sentences, AND tells you when something you wrote could be misunderstood. TypeScript's strict mode is that second proofreader — it doesn't just check the basics, it checks everything that could quietly cause a problem later. Without it, TypeScript is polite. With it, TypeScript is honest.

Most TypeScript developers enable strict mode on day one and never think about it again — and that's exactly the problem. When something eventually breaks because of an implicit any or an unchecked null, they have no idea why their 'typed' code behaved exactly like untyped JavaScript. Strict mode isn't just a setting you toggle; it's a philosophy about how seriously you take type safety. It's the difference between TypeScript as a linter and TypeScript as a genuine safety net.

TypeScript was designed to be gradually adoptable, which means its default settings are deliberately lenient. You can migrate a giant JavaScript codebase to TypeScript without changing a single line of logic — which sounds great until you realise you've just added TypeScript syntax without TypeScript's most valuable protections. The problems strict mode prevents — null dereferences, unintended any leakage, implicit function return types — are precisely the bugs that take hours to track down in production.

By the end of this article you'll understand exactly what each flag inside strict mode does, why each one was created, and how to use them in a real project. You'll be able to read a tsconfig.json and immediately know how protected that codebase really is — which is a skill that genuinely separates intermediate TypeScript developers from advanced ones.

What 'strict: true' Actually Enables Under the Hood

Setting 'strict: true' in your tsconfig.json is a shorthand. It doesn't flip one switch — it flips eight. Understanding each one individually is critical because when you migrate a legacy project you'll often need to turn them on one at a time, and when an error appears you need to know which rule it's coming from.

The eight flags that 'strict: true' enables are: strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables (added in TypeScript 4.4).

Each one was added in response to a real category of bugs that people kept shipping. They're not academic — they're battle scars. The two you'll feel immediately are noImplicitAny and strictNullChecks. Those two alone will surface more latent bugs in a typical codebase than everything else combined.

You can verify which flags are active at any time by running 'tsc --showConfig' in your terminal. It prints the fully resolved config including all inherited defaults, so there's no ambiguity about what's actually running.

tsconfig.json · JSON
1234567891011121314151617181920212223242526272829303132333435363738
{
  "compilerOptions": {
    // Enabling 'strict' is equivalent to enabling ALL eight flags below.
    // We're spelling them out here so you can see exactly what you're getting.
    "strict": true,

    // --- What 'strict: true' implicitly sets ---

    // 1. null and undefined are NOT assignable to other types.
    //    This is the single most impactful flag.
    "strictNullChecks": true,

    // 2. Variables can't silently become 'any' when TypeScript can't infer a type.
    "noImplicitAny": true,

    // 3. 'this' in functions must have an explicit type — prevents confusing runtime errors.
    "noImplicitThis": true,

    // 4. Class properties must be assigned in the constructor (or declared with '!').
    "strictPropertyInitialization": true,

    // 5. Function parameter types are checked contravariantly (prevents subtle type holes).
    "strictFunctionTypes": true,

    // 6. .bind(), .call(), .apply() are type-checked against the original function signature.
    "strictBindCallApply": true,

    // 7. Every JS file gets 'use strict' emitted at the top automatically.
    "alwaysStrict": true,

    // 8. Catch clause variables are typed as 'unknown' instead of 'any' (TS 4.4+).
    "useUnknownInCatchVariables": true,

    // Recommended companions (not part of 'strict' but work well with it)
    "noUncheckedIndexedAccess": true,  // array[index] returns T | undefined, not just T
    "exactOptionalPropertyTypes": true // { name?: string } means string, NOT string | undefined
  }
}
▶ Output
No runtime output — this is a config file. Run 'tsc --showConfig' to verify the resolved settings.
⚠️
Pro Tip:If you're enabling strict on a legacy project, add 'strict: true' then immediately add '// @ts-nocheck' to the files that explode. Fix them one file at a time. Never try to fix the whole repo at once — you'll give up on day two.

strictNullChecks and noImplicitAny: The Two Flags That Change Everything

These two flags deserve their own section because they're responsible for the vast majority of bugs strict mode catches. Without strictNullChecks, null and undefined can be assigned to any variable of any type — which is exactly how JavaScript works, and exactly why JavaScript is so error-prone.

Consider a function that fetches a user from a database. Without strictNullChecks, you can type the return value as 'User' even though the function clearly might return null when no user is found. TypeScript won't complain. Every caller of that function then assumes they have a User object and calls .name or .email on it — until one day a user isn't found and the whole thing crashes at runtime.

noImplicitAny closes a different gap. TypeScript infers types from usage, but when it genuinely can't infer — like an untyped function parameter — it has two choices: error, or silently assign 'any'. Without noImplicitAny it chooses silence. That silence erases type safety from that point forward in your code, like a hole in a net.

These two flags work together. strictNullChecks narrows the universe of values a type can hold. noImplicitAny ensures that universe is actually defined. Both are essential.

UserLookup.ts · TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// --- WITHOUT strict mode (the dangerous default) ---

// TypeScript doesn't complain here — but this function CAN return null.
// Without strictNullChecks, TypeScript believes the return type is just 'User'.
function findUserByIdUnsafe(userId: number) {
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob',   email: 'bob@example.com'   },
  ];
  // Array.find() returns T | undefined — but without strictNullChecks,
  // TypeScript lets you treat the result as always being T. Danger.
  return users.find(user => user.id === userId);
}

const unsafeUser = findUserByIdUnsafe(99); // User with id 99 does NOT exist
console.log(unsafeUser.name); // RUNTIME CRASH: Cannot read properties of undefined


// --- WITH strict mode (the safe, honest version) ---

interface User {
  id:    number;
  name:  string;
  email: string;
}

// With strictNullChecks active, TypeScript correctly infers the return
// type as 'User | undefined'. You're forced to handle both cases.
function findUserById(userId: number): User | undefined {
  const users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob',   email: 'bob@example.com'   },
  ];
  return users.find(user => user.id === userId);
}

// TypeScript now REFUSES to compile this without a null check:
// const brokenUser = findUserById(99);
// console.log(brokenUser.name); // ERROR: Object is possibly 'undefined'

// The correct pattern — narrow the type before using it:
const safeUser = findUserById(1);
if (safeUser !== undefined) {
  // TypeScript knows safeUser is type 'User' inside this block — not 'User | undefined'
  console.log(`Found user: ${safeUser.name} (${safeUser.email})`);
} else {
  console.log('No user found with that ID.');
}

// --- noImplicitAny in action ---

// WITHOUT noImplicitAny, TypeScript silently types 'data' as 'any'.
// Type safety is completely gone for this parameter and everything downstream.
function processDataUnsafe(data) { // TS would allow this without noImplicitAny
  return data.toUpperCase(); // Could crash if 'data' is a number
}

// WITH noImplicitAny — you must declare the type. No more silent escape hatches.
function processData(data: string): string {
  // TypeScript knows this is a string — .toUpperCase() is safe and autocomplete works
  return data.toUpperCase();
}

console.log(processData('hello, world')); // Output: HELLO, WORLD
▶ Output
Found user: Alice (alice@example.com)
HELLO, WORLD

(Without strict mode, line 10 would throw: TypeError: Cannot read properties of undefined (reading 'name'))
⚠️
Watch Out:Array.find(), Map.get(), and any DOM query (document.getElementById) all return T | undefined or null. These are the most common sources of null-related crashes. With strictNullChecks on, TypeScript forces you to handle the undefined case every single time. That's not inconvenient — that's the whole point.

strictPropertyInitialization and useUnknownInCatchVariables in Practice

These two flags are less talked about but protect against genuinely nasty bugs.

strictPropertyInitialization ensures that every property declared in a class is actually assigned a value — either in its declaration or in the constructor. Without it, you can declare a property as 'string' but never assign it, and TypeScript won't warn you. At runtime, accessing that property returns undefined — which breaks your string assumption silently.

The fix is always one of three things: assign it inline, assign it in the constructor, or add the definite assignment assertion (!) if you genuinely know it'll be assigned before use (like by a dependency injection framework).

useUnknownInCatchVariables was added in TypeScript 4.4 and solves a real problem: error handling. Before this flag, catch clause variables were typed as 'any'. That meant you could write 'error.message' without TypeScript complaining — even if the thrown value was a string, a number, or some custom object with no .message property. With this flag, caught errors are typed as 'unknown', forcing you to check the type before using any properties. It's annoying for five minutes, then it saves you hours.

OrderService.ts · TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// ================================================================
// strictPropertyInitialization — Class property safety
// ================================================================

class OrderService {
  // Without strictPropertyInitialization, you could declare these and
  // forget to assign them. At runtime, they'd both be 'undefined'.
  private apiBaseUrl: string;
  private maxRetries: number;

  constructor(apiBaseUrl: string, maxRetries: number) {
    // TypeScript verifies these assignments happen here.
    // Remove either line and you get: "Property has no initializer and
    // is not definitely assigned in the constructor."
    this.apiBaseUrl   = apiBaseUrl;
    this.maxRetries   = maxRetries;
  }

  getOrderEndpoint(orderId: string): string {
    // Safe — TypeScript knows apiBaseUrl is definitely a string here
    return `${this.apiBaseUrl}/orders/${orderId}`;
  }
}

// A class that uses lazy initialization (e.g. set by a framework after construction)
class ReportGenerator {
  // The '!' (definite assignment assertion) tells TypeScript:
  // "Trust me — this WILL be assigned before it's read."
  // Use this sparingly and only when you're 100% sure.
  private templateEngine!: { render: (template: string) => string };

  // Called by a framework (e.g. Angular's ngOnInit) after construction
  initialize(engine: { render: (template: string) => string }): void {
    this.templateEngine = engine;
  }

  generateReport(template: string): string {
    return this.templateEngine.render(template);
  }
}


// ================================================================
// useUnknownInCatchVariables — Safe error handling
// ================================================================

interface ApiError {
  statusCode: number;
  message:    string;
}

// A helper that checks if a thrown value matches our ApiError shape
function isApiError(error: unknown): error is ApiError {
  // We check every property before trusting the shape
  return (
    typeof error === 'object'  &&
    error !== null             &&
    'statusCode' in error      &&
    'message' in error
  );
}

async function fetchOrderDetails(orderId: string): Promise<void> {
  try {
    // Simulating a fetch call that might throw
    const response = await fetch(`https://api.example.com/orders/${orderId}`);

    if (!response.ok) {
      // We throw a structured object, not just a string
      throw { statusCode: response.status, message: 'Order not found' } as ApiError;
    }

    const order = await response.json();
    console.log('Order details:', order);

  } catch (error) {
    // With useUnknownInCatchVariables, 'error' is typed as 'unknown'.
    // TypeScript REFUSES to compile: console.log(error.message)
    // because it doesn't know error has a .message property.

    if (isApiError(error)) {
      // Inside this block TypeScript knows 'error' is ApiError
      console.error(`API Error ${error.statusCode}: ${error.message}`);
    } else if (error instanceof Error) {
      // Standard JS Error objects (network failures, etc.)
      console.error(`Network error: ${error.message}`);
    } else {
      // Someone threw a string, number, or something else entirely
      console.error('An unexpected error occurred:', String(error));
    }
  }
}

// Quick demo of the OrderService
const orderService = new OrderService('https://api.example.com', 3);
console.log(orderService.getOrderEndpoint('ORD-4892'));

// Calling fetchOrderDetails (won't actually run without a real server)
// fetchOrderDetails('ORD-4892');
▶ Output
https://api.example.com/orders/ORD-4892

// If fetchOrderDetails were called and the API returned a 404:
// API Error 404: Order not found
🔥
Interview Gold:Interviewers love asking about catch clause error typing. The correct answer is: with useUnknownInCatchVariables (part of strict mode since TS 4.4), caught errors are 'unknown' — not 'any'. You must use a type guard or instanceof check before accessing any properties. This forces genuinely safe error handling.

Migrating a Real Project to Strict Mode Without Breaking Everything

The biggest practical question isn't 'what does strict mode do?' — it's 'how do I turn it on in a codebase that didn't start with it?'

The naive approach is to add 'strict: true' and try to fix every error. In a large codebase this can surface hundreds of errors at once, which is demoralising and creates enormous PRs that are hard to review. There's a better strategy.

TypeScript supports per-file override via 'ts-ignore' and the newer '// @ts-nocheck' comment. The migration pattern that actually works in production: enable strict at the project level, immediately suppress all errors in existing files using @ts-nocheck, then remove the suppression comment from each file you touch during normal feature work. The codebase gets safer every sprint without ever blocking progress.

For greenfield projects, there's no excuse — strict: true should be in your tsconfig from the very first commit. The cost of enabling it later grows quadratically with codebase size. CRA, Vite, and Next.js all scaffold with strict mode on by default now, which is the right call.

Two flags worth adding beyond strict: 'noUncheckedIndexedAccess' (array indexing returns T | undefined) and 'exactOptionalPropertyTypes' (truly optional vs. explicitly undefined). Neither is in the strict bundle but both catch real bugs.

MigrationStrategy.ts · TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// ================================================================
// STEP 1: The problem noUncheckedIndexedAccess solves
// (Not in strict bundle — add it separately)
// ================================================================

const productNames: string[] = ['Laptop', 'Mouse', 'Keyboard'];

// WITHOUT noUncheckedIndexedAccess:
// TypeScript types productNames[5] as 'string' — but it's actually undefined at runtime
const firstProduct  = productNames[0]; // TypeScript says: string ✓
const missingProduct = productNames[5]; // TypeScript says: string ✗ (it's actually undefined)
// console.log(missingProduct.toUpperCase()); // RUNTIME CRASH

// WITH noUncheckedIndexedAccess:
// TypeScript correctly types productNames[n] as 'string | undefined'
// You're forced to check before using it
function getProductName(index: number): string {
  const product = productNames[index]; // Type: string | undefined

  if (product === undefined) {
    return 'Unknown Product';
  }

  return product; // Type narrowed to: string
}

console.log(getProductName(0)); // Laptop
console.log(getProductName(5)); // Unknown Product


// ================================================================
// STEP 2: Incremental migration pattern
// ================================================================

// In files not yet migrated, add this at the very top:
// // @ts-nocheck
// This suppresses ALL TypeScript errors in that file.
// It's a tactical retreat, not surrender — remove it file by file.


// ================================================================
// STEP 3: strictFunctionTypes — a subtle but important flag
// ================================================================

// This flag enforces contravariance on function parameter types.
// Here's a concrete example of the bug it prevents:

type StringHandler  = (value: string) => void;
type AnimalHandler  = (animal: { name: string; sound: string }) => void;

// Without strictFunctionTypes, TypeScript allowed assigning a more specific
// function type to a broader one — creating a silent type hole.
function processWithHandler(
  handler: (value: string) => void,
  input: string
): void {
  handler(input);
}

function uppercaseHandler(value: string): void {
  console.log(`Processed: ${value.toUpperCase()}`);
}

processWithHandler(uppercaseHandler, 'hello world');


// ================================================================
// STEP 4: A complete strict-mode tsconfig for a new project
// ================================================================

/*
{
  "compilerOptions": {
    "target":                        "ES2022",
    "module":                        "ESNext",
    "moduleResolution":              "bundler",
    "strict":                        true,
    "noUncheckedIndexedAccess":      true,
    "exactOptionalPropertyTypes":    true,
    "noImplicitReturns":             true,   // Functions must always return a value
    "noFallthroughCasesInSwitch":    true,   // switch cases must have break/return
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck":                  true
  }
}
*/

console.log(getProductName(0));
console.log(getProductName(5));
▶ Output
Laptop
Unknown Product
Processed: HELLO WORLD
Laptop
Unknown Product
⚠️
Pro Tip:Add 'noImplicitReturns: true' alongside strict mode. It forces every code path in a function to return a value. Without it, a function typed as returning 'string' can silently return 'undefined' if you forget an else branch — and TypeScript won't say a word.
FlagWhat It CatchesWithout It, You Risk
strictNullChecksnull/undefined used as non-nullable typesRuntime TypeError on .property access
noImplicitAnyUntyped function params/variables silently typed as 'any'Type safety silently disabled for entire code paths
strictPropertyInitializationClass properties declared but never assignedAccessing a property that's secretly undefined at runtime
useUnknownInCatchVariablesAccessing .message on a caught error without type checkingCrashing in your error handler — the worst place to crash
strictFunctionTypesUnsafe function type assignments that create type holesFunctions receiving the wrong argument types with no error
strictBindCallApply.call()/.bind()/.apply() called with wrong argument typesType-unsafe invocations that only fail at runtime
noUncheckedIndexedAccess (extra)Array/object indexing that might return undefinedTreating a potentially missing value as definitely present

🎯 Key Takeaways

  • 'strict: true' is a shorthand for eight separate flags — knowing each one individually means you can diagnose errors instantly and enable them incrementally during migration.
  • strictNullChecks and noImplicitAny are the two highest-impact flags — between them they prevent the two most common categories of TypeScript bugs: null dereferences and silent any leakage.
  • useUnknownInCatchVariables types caught errors as 'unknown', not 'any' — this forces you to write real error handling instead of assuming every thrown value is a standard Error object.
  • noUncheckedIndexedAccess and exactOptionalPropertyTypes aren't in the strict bundle but are worth adding to every greenfield project — they close real gaps that strict alone leaves open.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using '!' (non-null assertion) everywhere to silence strictNullChecks errors — The symptom is code that compiles but still crashes with TypeError in production. The '!' operator tells TypeScript 'this is definitely not null' — if you're wrong, TypeScript can't help you. Fix it by narrowing with an actual null check (if statement or optional chaining) instead of asserting your way out of the error.
  • Mistake 2: Setting strict: true in tsconfig but having a tsconfig.app.json or tsconfig.build.json that overrides it with strict: false — The symptom is that errors appear in your IDE but not in CI, or vice versa. Fix it by running 'tsc --showConfig' (not 'tsc --version') to see the actual resolved config your build is using. Always verify the file your build tool is actually reading.
  • Mistake 3: Typing caught errors as 'any' manually to work around useUnknownInCatchVariables — Developers write 'catch (error: any)' to get the old behaviour back. This defeats the entire purpose of the flag. Fix it by writing a type guard function (like 'isApiError' shown above) or using 'instanceof Error' for standard errors. The two-minute effort to write a guard prevents hours of debugging a broken error handler.

Interview Questions on This Topic

  • QWhat's the difference between enabling 'strict: true' and manually listing all the strict flags individually in tsconfig? When would you choose one over the other?
  • QWith strictNullChecks enabled, how would you safely handle a function that might return null or undefined? Walk me through at least two patterns.
  • QWhy are caught errors typed as 'unknown' rather than 'Error' in strict mode, and what does that tell you about how TypeScript thinks about exception safety?

Frequently Asked Questions

Does enabling strict mode in TypeScript affect runtime performance?

No. Strict mode is entirely a compile-time feature. It only changes what the TypeScript compiler checks and rejects — the JavaScript it emits is identical whether strict is on or off. All the type information is erased during compilation.

Should I enable strict mode in an existing JavaScript project I'm migrating to TypeScript?

Yes — but do it incrementally. Enable 'strict: true' immediately, then add '// @ts-nocheck' to every file that breaks. Remove the suppression comment file by file as you work through the codebase. This gives you the safety net on new code right away without blocking the migration.

What's the difference between 'unknown' and 'any' in catch clauses?

With 'any', TypeScript lets you access any property without checks — it trusts you completely. With 'unknown', TypeScript refuses to let you access any property until you prove the type via a type guard or instanceof check. 'unknown' is the safe default: it forces you to handle the possibility that the thrown value isn't what you expect.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousTypeScript vs JavaScriptNext →HTML Basics for JavaScript Developers
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged