DEV Community

Cover image for TypeScript strict Mode Is 8 Flags. Turn strictNullChecks On Last.
Gabriel Anhaia
Gabriel Anhaia

Posted on

TypeScript strict Mode Is 8 Flags. Turn strictNullChecks On Last.


It's a familiar tsconfig.json. The project is four years old, it
started on TypeScript 3.x, somebody enabled "strict": false, and
nobody ever flipped the switch. You open the file to fix that, type
"strict": true, save, and the editor reports 4,300 errors. You
revert, commit nothing, and the meeting is back on the calendar for
next quarter.

strict reads like a single dial, but it's eight independent flags
packaged into one shortcut. Most teams treat the shortcut as binary
because the docs lead with it, and that framing turns a one-week
migration into a quarter-long stalemate.

You can flip the eight flags individually. The cheapest two are
nearly free; the expensive two are where the 4,300 errors live. In
the right order, the migration ships in pull requests a reviewer
can read in one sitting, not a single mega-PR that nobody approves.

This post walks through the eight flags, what each one buys you,
the rough cost on a 30k-line codebase, and the order to enable
them on a project that has spent years coasting.

The eight flags behind strict: true

The TypeScript compiler treats "strict": true as the default
for eight options. You can override any of them individually. The
tsconfig reference
keeps the canonical list. Ordered by how hard each one bites a real
codebase, cheapest first:

  1. alwaysStrict — emits "use strict" and parses files in strict mode. Almost free on modern code.
  2. noImplicitThis — errors on this with an implicit any type.
  3. useUnknownInCatchVariablescatch (e) types e as unknown, not any.
  4. strictBindCallApply — type-checks bind/call/apply arguments against the function's signature.
  5. strictFunctionTypes — checks function parameters with proper contravariance for non-method positions.
  6. noImplicitAny — errors on values whose type cannot be inferred and that you have not annotated.
  7. strictPropertyInitialization — class properties must be assigned in the constructor or marked optional/definite.
  8. strictNullChecksnull and undefined are no longer members of every type.

The order above is not the order in the docs. It is the order you
turn them on when you are migrating a real project. The next
sections walk each flag, what it costs in errors per 30k lines,
and the patch shape that fixes the errors it surfaces.

1. alwaysStrict — the freebie

This flag adds "use strict" to every emitted file and parses your
source as strict-mode JavaScript. On any codebase written this
decade, the cost is zero. You are already not using with. You
are already not assigning to undefined. Modules are strict by
default in ES2015+ already.

{
  "compilerOptions": {
    "alwaysStrict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Expect 0–5 errors on a 30k-line project, all of them in files old
enough that the rest of the migration will rewrite them anyway.
First in the queue. Warm-up rep.

2. noImplicitThis — almost free

noImplicitThis errors when this inside a function has no
declared type and the compiler cannot infer one. The fix is a
this parameter, which compiles away at runtime.

// Before
function describe() {
  return `${this.name} (${this.kind})`;
}

// After
interface Describable {
  name: string;
  kind: string;
}

function describe(this: Describable): string {
  return `${this.name} (${this.kind})`;
}
Enter fullscreen mode Exit fullscreen mode

If your codebase uses arrow functions and class methods (the
default for anything written after 2018), this is rarely
implicit. The errors that do show up cluster in event handlers and
old prototype-style code.

5–30 errors on 30k lines, fixable in one PR. Comes second.

3. useUnknownInCatchVariables — the catch-block cleanup

Pre-strict TypeScript types catch (e) as any. With this flag,
e becomes unknown. You can still catch any throwable, but you
have to narrow before you read properties off it.

// Before — e is any, so e.message compiles fine and may crash
try {
  await doWork();
} catch (e) {
  log.error(e.message);
}

// After — narrow before you read
try {
  await doWork();
} catch (e: unknown) {
  if (e instanceof Error) {
    log.error(e.message);
  } else {
    log.error(String(e));
  }
}
Enter fullscreen mode Exit fullscreen mode

The fix is mechanical. A small helper module is worth writing once
so the narrowing pattern is consistent across the codebase:

export function asError(e: unknown): Error {
  return e instanceof Error ? e : new Error(String(e));
}
Enter fullscreen mode Exit fullscreen mode

Errors land mostly in catch blocks: 50–200 on a 30k-line project,
splittable into one PR per service or top-level directory. Third
slot.

4. strictBindCallApply — quiet wins

Without this flag, fn.bind(thisArg, ...args) returns a function
typed (...args: any[]) => any. With it, the compiler checks the
arguments against the function's signature.

function send(channel: string, payload: object): void {
  // ...
}

// Before — silently compiles, fails at runtime
const sendOrders = send.bind(null, 42);

// After — type error: 42 is not a string
const sendOrders = send.bind(null, "orders");
Enter fullscreen mode Exit fullscreen mode

The errors you get are usually genuine bugs that have been hiding
because nobody type-checked the bound version. On most modern
codebases, bind/call/apply usage is rare; people prefer arrow
functions and explicit closures. The errors are a quick read.

Usually 0–20 errors on a 30k-line project. Goes fourth.

5. strictFunctionTypes — the variance flag

This is the only flag that flips a type-system rule rather than
demanding annotations. Without it, function parameters are
bivariant — TypeScript accepts both wider and narrower argument
types when comparing function signatures. With it, parameters are
contravariant for non-method syntax, which is what type theory
actually says they should be.

type Animal = { name: string };
type Dog = Animal & { breed: string };

let handleAnimal: (a: Animal) => void;
let handleDog: (d: Dog) => void;

handleAnimal = handleDog;
// Without strictFunctionTypes: allowed (and unsound)
// With strictFunctionTypes: error — Dog is narrower than Animal
Enter fullscreen mode Exit fullscreen mode

The fix is rarely a code change; it is a type change. Either widen
the parameter, or narrow the variable's declared type, or use a
method-shorthand signature where bivariance is preserved
intentionally.

Roughly 10–50 errors on 30k lines, clustered around event-handler
callbacks and generic adapter layers. Fifth.

6. noImplicitAny — the typing tax

The migration changes shape at this flag. noImplicitAny errors
on any value whose type the compiler cannot infer and that you
have not annotated: function parameters without annotations,
variables initialized to null without a type, object property
accesses where the index signature is unknown.

// Before — params are implicitly any
function transformRow(row, index) {
  return { ...row, position: index };
}

// After — explicit types
interface Row {
  id: string;
  value: number;
}

function transformRow(row: Row, index: number): Row & { position: number } {
  return { ...row, position: index };
}
Enter fullscreen mode Exit fullscreen mode

The volume scales with how much of the codebase predates TS or was
written by a developer who treated annotations as optional. On a
codebase that started in JavaScript and was renamed to .ts
without much further work, expect a lot. On a codebase that has
been TS-native from day one, expect very little.

The lazy fix is any everywhere, but resist that. The right fix
is a real type, even if it is unknown with a narrowing guard a
few lines later. any defeats the migration; you flip the flag
and the codebase still has no real types.

200–1500 errors on 30k lines depending on the codebase's history.
Multiple PRs, organized by directory or module boundary. Sixth in
the queue.

7. strictPropertyInitialization — the class-field flag

Class properties must be assigned in the constructor, declared with
a definite-assignment assertion (!), or typed as optional
(?).

// Before — id is implicitly possibly undefined at runtime
class Connection {
  id: string;
  open() {
    this.id = crypto.randomUUID();
  }
}

// After — assign in the constructor, mark as definite, or optional
class Connection {
  id: string;
  constructor() {
    this.id = crypto.randomUUID();
  }
}

// Or, if a framework assigns the field after construction:
class Injected {
  service!: Service;
}

// Or, if it really is optional:
class MaybeReady {
  ready?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

This flag only does its real work once strictNullChecks is on.
Without null checks, every property is implicitly nullable already,
so "uninitialized" is invisible to the type checker. TypeScript
still enforces the constructor-assignment rule, but the errors are
quieter. The practical recommendation is to flip this in the same
window as strictNullChecks, not as an isolated PR.

The trap is the ! escape hatch. It silences the error without
fixing anything. For framework-injected fields it is sometimes the
right call (Angular, NestJS, MikroORM — frameworks that assign
fields outside any visible constructor). For everything else,
treat it as a code smell.

50–300 errors on 30k lines, concentrated in DTO classes,
repositories, and anything written before record-style data classes
were idiomatic. Seventh on the list, paired with the eighth.

8. strictNullChecks — the wall

This is the flag that holds up most migrations. Without it,
null and undefined are members of every type. With it, they
are their own thing, and you have to acknowledge them at every
boundary where they might appear.

The error count from this flag alone often exceeds the count from
the other seven combined. A codebase that has happily passed
null around for years suddenly sees errors in:

  • Every API response shape (HTTP fields are optional in the wire protocol but were typed as required in TS).
  • Every database query result (Postgres NULL columns mapped to string instead of string | null).
  • Every form field state.
  • Every Map.get call (which legitimately returns T | undefined).
  • Every optional config option.
// Before — config.port could be undefined; nothing complains
function bind(config: { port?: number }) {
  return server.listen(config.port);
}

// After — error: number | undefined not assignable to number
function bind(config: { port?: number }) {
  return server.listen(config.port ?? 8080);
}
Enter fullscreen mode Exit fullscreen mode

The toolkit is ?? defaults, optional chaining (?.), non-null
assertions (!), and explicit narrowing. What matters is reading
each error and asking "is this a real bug?" — because about 5–10%
of them are. The other 90% are spots where the compiler now demands
a default you have always silently relied on.

A few patterns that pay off on this flag specifically:

// Pattern 1: discriminated unions for "loaded" states
type Loadable<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "ok"; value: T }
  | { status: "error"; error: Error };

// Pattern 2: branded "definitely-non-null" types at API boundaries
function requireEnv(key: string): string {
  const v = process.env[key];
  if (v === undefined) throw new Error(`Missing env: ${key}`);
  return v;
}

// Pattern 3: zod / valibot at the parse boundary
const Config = z.object({
  port: z.number().default(8080),
  host: z.string(),
});
type Config = z.infer<typeof Config>;
Enter fullscreen mode Exit fullscreen mode

1000–4000 errors on 30k lines. Last in the queue, budgeted as its
own multi-week project rather than a single PR.

The order, in one screen

The order condensed, with rough error counts per 30k lines on a
project that started lax and stayed that way. These are heuristic
ranges, not measured benchmarks — your numbers will vary by an
order of magnitude depending on how much JavaScript heritage the
project carries.

Order Flag Cost (errors) Patch shape
1 alwaysStrict 0–5 Trivial
2 noImplicitThis 5–30 this: parameter
3 useUnknownInCatchVariables 50–200 instanceof narrow
4 strictBindCallApply 0–20 Genuine bug fixes
5 strictFunctionTypes 10–50 Variance fix
6 noImplicitAny 200–1500 Annotations
7 strictPropertyInitialization 50–300 Constructor assign
8 strictNullChecks 1000–4000 Multi-week effort

The shape matters more than the absolute numbers: the first five
flags together are usually under 10% of the total error budget,
and strictNullChecks alone is usually 60–80% of it.

Why this order, not alphabetical

The cheap flags front-load momentum. After flags 1–5 land, the
team has shipped a real migration and built reviewer muscle for
type-driven PRs. By the time noImplicitAny and strictNullChecks
arrive, the workflow is muscle memory, not a new ask on top of
4,000 errors.

Each of these flags can also live behind a per-file or per-directory
override. The TypeScript project references
mechanism, plus directory-specific tsconfigs, lets you turn
strictNullChecks on for one bounded context (the new payments
module, say) while the legacy invoicing service stays on the old
config. The migration ships in increments.

// @ts-expect-error and // @ts-nocheck are the per-file escape
hatches if you need them. They are better than any because they
mark the spot for cleanup later. They are worse than a real fix
because they freeze the bug in place. Use them when the deadline
demands it; track them in a TODO list; come back next sprint.

A small bet that pays off

If your tsconfig has "strict": false and a comment that says
"someday", today is the day to turn on alwaysStrict and
noImplicitThis. They will land in one PR with single-digit
errors. The team will see that the migration is real and tractable.
Next week, useUnknownInCatchVariables. The week after,
strictBindCallApply and strictFunctionTypes. By the time you
get to noImplicitAny, you have a rhythm.

The eight-flag staircase is what turns "we can't migrate to strict
mode" into "we already did half of it and the team didn't notice."


If this was useful

Strict-mode tuning is the first thing TypeScript Essentials
walks through on a daily-driver project — what each flag changes
about narrowing, where the migration tax actually lands, and how
to set up per-directory configs so you can ship the migration in
PRs a reviewer can actually read. If the eight-flag staircase
mapped onto something on your desk, that is the book to put next
to it.

TypeScript in Production picks up where this post ends, on the
build, target, and tsconfig decisions that scale from a single
service to a monorepo of libraries shipping across Node, Bun, and
Deno. The TypeScript Type System is the deep dive on the
generics, mapped types, and infer patterns that compose with a
strict-mode codebase into library-grade APIs.

If you are coming from JVM languages where null safety is already
baked into Kotlin and the type checker is taken as a given,
Kotlin and Java to TypeScript makes the bridge into TypeScript's
structural model. If you are coming from PHP 8+, PHP to
TypeScript
covers the same ground from the other side, including
how strictNullChecks lines up against PHP's ?type and
null|type annotations.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

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

The TypeScript Library — the 5-book collection

Top comments (0)