DEV Community

Cover image for The 6 tsconfig Flags That Decide Whether Strict Mode Actually Helps
Gabriel Anhaia
Gabriel Anhaia

Posted on

The 6 tsconfig Flags That Decide Whether Strict Mode Actually Helps


You turned on strict. The PR was clean. Your team felt good
about it. Then a request comes in, body.items[0] is read on an
empty array, and undefined flows three functions deep before
it throws Cannot read properties of undefined. The stack trace
points at code that compiled without a single warning.

strict did not catch it. strict was never going to catch it.

strict is a bundle of eight sub-flags, and most teams treat it
as the finish line. It is the floor. The eight flags it turns on
stop implicit any, null-versus-non-null confusion, and a few
function-variance traps. They do not stop array indexing from
lying, optional properties from being assigned undefined
explicitly, or a renamed base method from silently orphaning its
override. Those bugs live in flags strict does not set.

Here are six flags that decide whether strict mode actually
earns its reputation, and the exact error each one surfaces. Two
of the six bring a sibling flag along, so the final config lists
eight lines — but the six below are the decisions worth making.

1. noUncheckedIndexedAccess — the one that matters most

This is the flag that catches the bug in the opening paragraph.
Under plain strict, indexing an array or a record gives you
the element type with no acknowledgment that the index might be
out of bounds.

const items: string[] = ["a", "b"];
const first = items[0];
// first: string  (even though it could be undefined)

const map: Record<string, number> = {};
const n = map["missing"];
// n: number  (it is actually undefined)
Enter fullscreen mode Exit fullscreen mode

With noUncheckedIndexedAccess: true, every indexed read picks
up | undefined, and the compiler forces you to handle it.

const first = items[0];
// first: string | undefined

console.log(first.toUpperCase());
// error TS18048: 'first' is possibly 'undefined'.
Enter fullscreen mode Exit fullscreen mode

You narrow it the way you already narrow nullable values.

const first = items[0];
if (first !== undefined) {
  console.log(first.toUpperCase()); // ok
}
Enter fullscreen mode Exit fullscreen mode

The cost is real: for loops over a known-length array now
complain, and you reach for ?. and guards more often. The
payoff is that the single most common runtime crash in
JavaScript (reading a property off an array slot that was not
there) becomes a compile error. If you turn on exactly one flag
from this list, turn on this one.

2. exactOptionalPropertyTypesundefined is not "absent"

TypeScript treats key?: string and key: string | undefined
as nearly interchangeable by default. They are not the same
thing. One says "the key may be absent." The other says "the key
is present and its value may be undefined." That distinction
matters the moment you serialize, diff, or spread an object.

interface Options {
  timeout?: number;
}

const o: Options = { timeout: undefined };
// allowed by default
Enter fullscreen mode Exit fullscreen mode

Under exactOptionalPropertyTypes: true, assigning undefined
to an optional property is rejected unless you spelled out
undefined in the type.

const o: Options = { timeout: undefined };
// error TS2375: Type '{ timeout: undefined }' is not
// assignable to type 'Options' with
// 'exactOptionalPropertyTypes: true'. Consider adding
// 'undefined' to the type of the property.
Enter fullscreen mode Exit fullscreen mode

The flag forces you to choose. Either the key is absent:

const o: Options = {};
Enter fullscreen mode Exit fullscreen mode

Or undefined is a deliberate value, and you say so:

interface Options {
  timeout?: number | undefined;
}
Enter fullscreen mode Exit fullscreen mode

This catches a class of bug around JSON.stringify (which drops
undefined keys but keeps null), Object.keys counts, and
React props where prop={undefined} and omitting the prop take
different code paths. It is the most pedantic flag on the list
and the one most likely to generate noise in an existing
codebase. Turn it on early in a project's life, not late.

3. noImplicitOverride — the rename that orphans a method

You override a base-class method. A year later someone renames
the base method. Your override no longer overrides anything — it
is now a brand-new method that nobody calls, and the base
behavior runs instead. Nothing fails to compile. The bug is a
silent behavior change.

class Base {
  handle(): void { /* base logic */ }
}

class Derived extends Base {
  handle(): void { /* the override you rely on */ }
}
Enter fullscreen mode Exit fullscreen mode

Rename handle to process in Base, and Derived.handle is
now dead weight. With noImplicitOverride: true, every method
that overrides a base member must say override, and a method
marked override that no longer matches a base member is an
error.

class Derived extends Base {
  override handle(): void { /* ... */ }
}
// after Base.handle is renamed:
// error TS4113: This member cannot have an 'override'
// modifier because it is not declared in the base class
// 'Base'.
Enter fullscreen mode Exit fullscreen mode

The flag is cheap to adopt: the compiler's quick-fix adds the
override keyword across the codebase in one pass. It pays
off every time someone refactors a base class. If you write any
class hierarchies at all, this one is close to free.

4. noUnusedLocals and noUnusedParameters — dead code as an error

These two are not part of strict, and they catch the residue
that linters often miss in the type layer: a variable you
assigned and never read, an import you stopped using, a function
parameter left behind after a signature change.

import { readFile, writeFile } from "node:fs/promises";

function load(path: string, encoding: string): string {
  const raw = readFile(path);
  return "stub";
}
Enter fullscreen mode Exit fullscreen mode

With both flags on:

// error TS6133: 'writeFile' is declared but its value
// is never read.
// error TS6133: 'encoding' is declared but its value
// is never read.
Enter fullscreen mode Exit fullscreen mode

For parameters you genuinely need to keep (interface
conformance, a callback signature), prefix with an underscore to
opt out:

function onEvent(_event: string, data: Payload): void {
  use(data);
}
Enter fullscreen mode Exit fullscreen mode

These flags keep the orphaned-import and stale-parameter drift
out of your diffs. They sit in the same family as
noFallthroughCasesInSwitch, which turns an accidental
fall-through between case labels into an error — another bug
strict leaves alone.

5. verbatimModuleSyntax — type imports that survive emit

This flag changed how type-only imports work, and it is the one
most likely to surprise you on an upgrade. Without it, the
compiler decides which imports are types and erases them at
emit. That guesswork breaks in two directions: a value import
gets erased because the compiler thought it was a type, or a
type import survives into the output and a bundler tries to load
a module that has no runtime side effects.

verbatimModuleSyntax: true removes the guessing. The rule
becomes literal: anything written as import type is erased,
anything written as a plain import is emitted exactly as
written.

import { type User, createUser } from "./user";
Enter fullscreen mode Exit fullscreen mode

That line emits an import for createUser and erases User.
Mix the two incorrectly and the compiler tells you:

import { User } from "./user";
const u: User = createUser();
// error TS1484: 'User' is a type and must be imported
// using a type-only import when 'verbatimModuleSyntax'
// is enabled.
Enter fullscreen mode Exit fullscreen mode

The fix is to be explicit about which imports are types:

import type { User } from "./user";
import { createUser } from "./user";
Enter fullscreen mode Exit fullscreen mode

The flag matters most when you ship a library or run in an
environment that strips types without a full type-checker
(esbuild, swc, Bun's transpiler, Node's type-stripping). Those
tools cannot do the elision guesswork tsc used to do, so they
rely on you having written import type where you meant it.
verbatimModuleSyntax makes tsc enforce the same discipline at
build time, so the fast transpiler in production agrees with the
type-checker in CI.

6. noImplicitReturns — the function that forgets a path

strict does not require every code path in a function to
return. A switch that handles three of four cases, an if
without an else, a guard clause that drops through — these all
return undefined on the missing path, and strict is fine
with it as long as the type allows undefined.

function label(s: Status): string {
  switch (s) {
    case "open":   return "Open";
    case "closed": return "Closed";
    // "pending" falls through and returns undefined
  }
}
Enter fullscreen mode Exit fullscreen mode

With an explicit return type that permits undefined, or an
inferred one that widens to include it, strict lets the missing
path slide. With noImplicitReturns: true, any function with a
reachable path that does not return (when other paths do) is an
error.

// error TS7030: Not all code paths return a value.
Enter fullscreen mode Exit fullscreen mode

Pair this with a union type and exhaustive switch handling and
you get the same guarantee discriminated unions are supposed to
give you: add a new variant to Status, and every function that
forgot to handle it fails to compile. Without the flag, those
functions quietly return undefined and the bug ships.

The shape of a config that actually helps

strict belongs at the top. It is the eight-flag baseline and
you should not ship without it. But it is the start of the list,
not the whole list.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,

    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "verbatimModuleSyntax": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Adopt them in order of return per unit of friction:
noImplicitOverride and the noUnused* pair are close to free
on most codebases. noImplicitReturns and
noFallthroughCasesInSwitch catch real control-flow gaps with
modest noise. verbatimModuleSyntax is a one-time import
cleanup that pays off the day you swap tsc for a faster
transpiler. noUncheckedIndexedAccess is the highest-value flag
and the one with the most short-term friction, so give it a
dedicated PR. exactOptionalPropertyTypes is the pickiest, so
turn it on while the codebase is small.

strict: true tells you the compiler is on your side. These six
flags decide how much that actually buys you.

If this was useful

TypeScript in Production works through the whole tsconfig
surface end to end: which flags are baseline, which ones earn
their friction, and how the build and transpiler layers have to
agree on type elision. It covers monorepo setup,
dual ESM/CJS publishing, and library authoring across runtimes.
If the config section above is the part you bookmarked, that is
roughly where the book lives.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)