typescript · level 9

Strict Mode & TSConfig

The flags that turn TypeScript from suggestions into enforcement.

175 XP

Strict Mode & TSConfig

Out of the box, TypeScript is permissive — too permissive. The only setting that matters is strict, and even with it on, there are extra opt-ins that catch real bugs. This lesson walks through the flags that actually pay off, ordered by ROI.

strict: true — the foundation

"strict": true is shorthand for the entire strict family:

  • strictNullChecksnull/undefined are their own types, not values of every type.
  • noImplicitAny — function parameters, variable initialisers, and return types must have a type.
  • strictFunctionTypes — function parameter types are checked contravariantly (mathematically correct).
  • strictBindCallApplyFunction.prototype.bind/call/apply are typed.
  • strictPropertyInitialization — class fields must be assigned in the constructor.
  • noImplicitThisthis must be typed when not implicit.
  • useUnknownInCatchVariablescatch (e) types e as unknown, not any.
  • alwaysStrict — emit "use strict" and parse in strict mode.

If you take exactly one thing from this lesson: always start a project with strict: true. Adding strict mode to a mature codebase is brutal; starting strict is free.

The two flags strict doesn't include

noUncheckedIndexedAccess

The single most useful non-strict flag. Without it:

const arr = ["a", "b"];
const c: string = arr[5];   // typed as string — but it's actually undefined!
c.length;                    // crashes at runtime

With it:

const c: string | undefined = arr[5];   // forced to handle the maybe-missing case

Same applies to Record types and untyped object keys. Catches a category of bug TypeScript would otherwise wave through.

exactOptionalPropertyTypes

Distinguishes "absent" from "present-but-undefined":

interface User { email?: string }

// Without exactOptionalPropertyTypes (the default):
const u: User = { email: undefined };   // ✓ legal — undefined is "missing enough"

// With exactOptionalPropertyTypes:
const u: User = { email: undefined };   // ❌ undefined not assignable to string
const u: User = {};                      // ✓ truly absent
delete u.email;                          // ✓ legal way to clear

Useful when you're modelling JSON or a precise wire format. Painful when interacting with libraries that assume undefined clears an optional. Most teams skip this one until it earns its way in.

The "I caught a bug" flags

These don't have category-defining names, but they each prevent a specific common mistake:

noFallthroughCasesInSwitch

switch (k) {
  case 1: doA();         // ❌ missing break
  case 2: doB(); break;
}

If you forgot the break and didn't intend fall-through, this catches it. Fall-through that's intentional needs a // falls through comment.

noImplicitReturns

function pick(b: boolean): number {
  if (b) return 1;
  // ❌ Not all code paths return a value
}

Catches missing return statements in branches.

noUnusedLocals / noUnusedParameters

Errors on dead code. Forces you to delete or _-prefix. (function f(_x: number) is the convention for an intentionally-unused parameter.)

noImplicitOverride

class Base { greet() { /* … */ } }
class Sub extends Base {
  override greet() { /* … */ }   // ✓ explicit
  greet() { /* … */ }            // ❌ requires `override` modifier
}

Prevents the bug where a parent class adds a method that silently shadows a similarly-named child method.

Module / output flags

isolatedModules

Required by single-file transpilers (Vite, esbuild, swc — anything that compiles one file at a time). Disallows features that need full-program type info, like const enum. Modern projects should turn this on.

verbatimModuleSyntax

Forces every type-only import to be marked as such (import type X from …). Removes ambiguity for build tools — no more guessing whether to keep an import.

skipLibCheck

Skip type-checking your dependencies' .d.ts files. Saves a lot of build time at the cost of catching bugs in libraries you depend on (rare, and not your problem to fix). Effectively-mandatory for any non-trivial project.

Build performance

incremental and tsBuildInfoFile

Caches type-check info across runs. Massive speedup for large projects.

composite and project references

Splits a monorepo into separately-compiled projects. Past a certain size, makes the difference between "tsc takes 90 seconds" and "tsc takes 5 seconds for the changed package".

Sensible default tsconfig.json

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "module": "nodenext",
    "moduleResolution": "nodenext",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,

    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,

    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,

    "outDir": "dist",
    "rootDir": "src",
    "incremental": true,

    "noEmit": true            // most teams emit via Vite/esbuild/swc, not tsc
  },
  "include": ["src/**/*"],
  "exclude": ["**/*.test.ts", "node_modules", "dist"]
}

You can extend @tsconfig/strictest from npm and override what you don't want — saves writing this every time.

How to migrate

For an existing codebase:

  1. Turn on strict first. Fix the errors, even if it takes a week.
  2. Then noUncheckedIndexedAccess. The fewest false-positives, the most real bugs.
  3. Add noUnusedLocals/Parameters. Cleans the codebase as a side-effect.
  4. Add the smaller flags one at a time. noImplicitOverride, noFallthroughCasesInSwitch, etc.
  5. exactOptionalPropertyTypes last. It cascades into a lot of files; do it once you have headroom.

Most teams reach a stable strictest config and stop. Past that, additional flags are diminishing returns.

What tsc actually does

tsc does two things, often confused:

  1. Type-check — read the program, error on type mismatches. Fast in modern setups.
  2. Emit — produce JS files corresponding to your TS files. Slower; usually skipped.

In a typical Vite/Next.js setup:

  • Vite/Next compiles your TS to JS (via esbuild/swc, no type checking).
  • tsc --noEmit runs as a separate CI job to catch type errors.

This split is why your dev server can be instant but your CI still catches type bugs. The noEmit: true in the config above codifies that split.

What to put in tsconfig that isn't a strict flag

A short list of "obvious now, surprising later":

  • paths for any alias longer than ../../. @/lib/x reads better than ../../lib/x and survives moving files.
  • types: ["vitest/globals"] (or jest, or whatever) — pulls in your test framework's globals.
  • jsx: "preserve" for React projects that bundle separately. "react-jsx" if you want tsc to emit the new JSX runtime.

The takeaway

The cost of strict mode is one-time. The cost of not using it is unbounded — every any is a runtime crash waiting to happen. Configure once, never look back.

Tools in the wild

3 tools