Skip to content

Compiler Options — Middle Level

Table of Contents

  1. Prerequisites
  2. The strict Umbrella
  3. The Strict Family, Flag by Flag
  4. noImplicitAny
  5. strictNullChecks
  6. strictFunctionTypes
  7. strictBindCallApply
  8. strictPropertyInitialization
  9. noImplicitThis
  10. useUnknownInCatchVariables
  11. alwaysStrict
  12. Beyond strict: Extra Correctness Flags
  13. noUnusedLocals and noUnusedParameters
  14. noImplicitReturns
  15. noFallthroughCasesInSwitch
  16. exactOptionalPropertyTypes
  17. noUncheckedIndexedAccess
  18. Module Resolution at the Middle Level
  19. Putting It Together
  20. When and Why to Enable Each Flag
  21. Middle Checklist
  22. Common Mistakes
  23. Test
  24. Summary
  25. Further Reading

Prerequisites

  • You set strict: true reflexively in new projects (from the Junior level).
  • You are comfortable with interfaces, type aliases, unions, narrowing, and basic generics.
  • You build real apps (React, Node, or similar) and have hit type errors you had to think about.
  • You can read emitted JavaScript and recognize CommonJS vs ESM output.

This page goes deep on what each strict sub-flag actually catches and the additional correctness flags every senior codebase turns on. By the end you should be able to justify each flag in a code review.


The strict Umbrella

strict: true is shorthand. Internally it sets a group of flags to true. You can also set strict: true and then opt out of one specific flag:

{
  "compilerOptions": {
    "strict": true,
    "strictPropertyInitialization": false
  }
}

The flags that strict controls:

Flag Added in One-line effect
noImplicitAny 1.0 Error when a type silently becomes any
strictNullChecks 2.0 null/undefined are their own types
strictFunctionTypes 2.6 Function parameters checked contravariantly
strictBindCallApply 3.2 bind/call/apply are type-checked
strictPropertyInitialization 2.7 Class fields must be initialized
noImplicitThis 2.0 Error on this with an implicit any type
useUnknownInCatchVariables 4.4 catch (e) gives e: unknown not any
alwaysStrict 2.1 Emit "use strict" and parse in strict mode

Each flag is independently settable, so you can adopt strictness gradually by turning strict off and enabling individual flags one at a time (covered in senior.md).


The Strict Family, Flag by Flag

noImplicitAny

When TypeScript cannot infer a type and would otherwise fall back to any, this flag turns that into an error. It forces you to be explicit instead of silently losing type safety.

// noImplicitAny: false  → param is silently 'any'
function double(x) {
  return x * 2; // x is any; no help, no checking
}

// noImplicitAny: true   → error
function double(x) {
  //            ~ Error: Parameter 'x' implicitly has an 'any' type.
  return x * 2;
}

// Fix: annotate
function double(x: number) {
  return x * 2;
}

It also fires on implicit-any from index access and untyped imports. This is the foundational strict flag — without it, large swaths of code go unchecked.

strictNullChecks

The most impactful flag. Without it, null and undefined are assignable to every type, which hides the most common class of runtime crashes. With it, they become distinct types you must handle.

// strictNullChecks: false → compiles, crashes at runtime
function firstChar(s: string) {
  return s.charAt(0);
}
firstChar(null); // boom at runtime

// strictNullChecks: true → caught at compile time
firstChar(null);
//        ~~~~ Error: Argument of type 'null' is not assignable to parameter of type 'string'.

It changes how optional values flow:

interface User { name: string; nickname?: string }

function show(u: User) {
  // u.nickname is string | undefined under strictNullChecks
  console.log(u.nickname.toUpperCase());
  //          ~~~~~~~~~~~ Error: 'u.nickname' is possibly 'undefined'.
}

You handle it with narrowing:

function show(u: User) {
  if (u.nickname) {
    console.log(u.nickname.toUpperCase()); // narrowed to string
  }
}

strictFunctionTypes

Makes function-type parameters checked contravariantly (the mathematically sound rule), catching unsafe callback assignments. It applies to function types written as (x) => y, not to methods (a deliberate carve-out for usability).

declare let f: (x: string | number) => void;
declare let g: (x: string) => void;

// strictFunctionTypes: true
f = g; // Error: a (string)=>void cannot stand in for a (string|number)=>void,
       // because f might be called with a number, which g cannot handle.

Without the flag, this unsafe assignment is allowed and can blow up when f is called with a number.

strictBindCallApply

Type-checks the arguments and return type of Function.prototype.bind, call, and apply. Before this flag those methods accepted anything.

function greet(name: string, age: number) {
  return `${name} is ${age}`;
}

// strictBindCallApply: true
greet.call(undefined, "Ada", "thirty");
//                            ~~~~~~~~~ Error: Argument of type 'string' is not
//                            assignable to parameter of type 'number'.

const bound = greet.bind(null, "Ada"); // bound: (age: number) => string
bound(42); // OK, fully typed

strictPropertyInitialization

Requires that class instance properties are assigned in the constructor (or have an initializer), so you cannot read an undefined field. It depends on strictNullChecks being on.

// strictPropertyInitialization: true
class Service {
  client: HttpClient;
  // ~~~~~~ Error: Property 'client' has no initializer and is not
  //        definitely assigned in the constructor.
}

// Fixes:
class Service {
  client: HttpClient = new HttpClient();        // initializer
}
class Service {
  client: HttpClient;
  constructor() { this.client = new HttpClient(); } // constructor assignment
}
class Service {
  client!: HttpClient; // definite assignment assertion — "trust me, it's set later"
}

The ! escape hatch is for cases like dependency injection frameworks that assign fields after construction.

noImplicitThis

Errors when this has an implicit any type — usually a standalone function that uses this without declaring what it is.

// noImplicitThis: true
function handler() {
  console.log(this.id);
  //          ~~~~ Error: 'this' implicitly has type 'any'.
}

// Fix: declare a `this` parameter
function handler(this: { id: string }) {
  console.log(this.id); // OK
}

This catches a classic JavaScript footgun where this is not what you think it is.

useUnknownInCatchVariables

Before TypeScript 4.4, the variable in catch (e) was typed any. With this flag (on by default under strict since 4.4) it is unknown, forcing you to narrow before using it. This is correct because a thrown value can be anything — not just an Error.

// useUnknownInCatchVariables: true
try {
  doWork();
} catch (e) {
  console.log(e.message);
  //          ~ Error: 'e' is of type 'unknown'.
}

// Fix: narrow first
try {
  doWork();
} catch (e) {
  if (e instanceof Error) {
    console.log(e.message); // narrowed to Error
  } else {
    console.log("Non-error thrown:", e);
  }
}

alwaysStrict

Parses every file in ECMAScript strict mode and emits "use strict"; at the top of output (when the module format allows it). It catches strict-mode-only errors like assigning to a read-only global or using a reserved word as a variable name. It is almost always desirable and is part of strict.

// alwaysStrict: true → emitted JS begins with "use strict";
// and constructs illegal in strict mode (e.g. `with` statements) are errors.

Beyond strict: Extra Correctness Flags

These are not part of strict. You opt into them. Every mature codebase enables most of them.

noUnusedLocals and noUnusedParameters

Report dead variables and parameters. They keep code clean and catch typos (you imported something and forgot to use it because you spelled the usage wrong).

// noUnusedLocals: true
function calc() {
  const total = 0; // Error: 'total' is declared but its value is never read.
  return 42;
}

// noUnusedParameters: true
function onClick(event: MouseEvent) {
  //             ~~~~~ Error: 'event' is declared but its value is never read.
  doSomething();
}
// Convention: prefix with _ to intentionally ignore
function onClick(_event: MouseEvent) {
  doSomething(); // _event is allowed unused
}

noImplicitReturns

Errors when some code paths return a value and others fall off the end implicitly returning undefined. It forces every branch to be explicit.

// noImplicitReturns: true
function classify(n: number): string {
  if (n > 0) return "positive";
  if (n < 0) return "negative";
  // Error: Not all code paths return a value. (zero falls through)
}

// Fix
function classify(n: number): string {
  if (n > 0) return "positive";
  if (n < 0) return "negative";
  return "zero";
}

noFallthroughCasesInSwitch

Errors when a non-empty case falls through to the next without break, return, or throw. This catches the classic switch bug.

// noFallthroughCasesInSwitch: true
switch (status) {
  case "active":
    notify();
    // Error: Fallthrough case in switch.
  case "inactive":
    archive();
    break;
}

// Empty fallthrough is still allowed (intentional grouping):
switch (status) {
  case "active":
  case "pending":
    keep();
    break;
}

exactOptionalPropertyTypes

Makes a distinction between "property absent" and "property present but set to undefined". Without it, { x?: number } silently allows { x: undefined }. With it, optional means may be missing, not may be explicitly undefined.

interface Options { timeout?: number }

// exactOptionalPropertyTypes: true
const a: Options = {};                 // OK — absent
const b: Options = { timeout: 1000 };  // OK — present
const c: Options = { timeout: undefined };
//                   ~~~~~~~ Error: Type '{ timeout: undefined }' is not assignable...
//                   Consider adding 'undefined' to the type: 'timeout?: number | undefined'.

This matters for code that uses "key" in obj checks, where the two cases behave differently at runtime.

noUncheckedIndexedAccess

Adds | undefined to the result of indexed access on arrays and objects with index signatures. It reflects the reality that arr[i] might be out of bounds.

// noUncheckedIndexedAccess: true
const arr = [1, 2, 3];
const x = arr[10]; // x: number | undefined (not number!)
console.log(x.toFixed(2));
//          ~ Error: 'x' is possibly 'undefined'.

// Record index access also becomes safe
const map: Record<string, number> = { a: 1 };
const v = map["missing"]; // v: number | undefined

It is the most disruptive non-strict flag (it touches every array access), so teams often adopt it deliberately. It eliminates a huge class of "cannot read property of undefined" crashes.


Module Resolution at the Middle Level

module and moduleResolution are easy to confuse:

  • module — the output module format (what import compiles to).
  • moduleResolution — the algorithm TypeScript uses to find a module on disk.
moduleResolution Behavior Use for
node (a.k.a. node10) Classic CommonJS resolution; ignores exports map Legacy projects
node16 / nodenext Honors package.json "exports", "type", file extensions Modern Node.js
bundler Like node16 but relaxed (no mandatory file extensions in imports) Vite, esbuild, webpack
// Modern Node project
{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" } }

// Bundled web app
{ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler" } }

Two helpers you will set often:

  • resolveJsonModule: true — allows import data from "./data.json" with typed JSON.
  • esModuleInterop: true — generates interop helpers so import express from "express" works on CommonJS packages, and implies allowSyntheticDefaultImports.

baseUrl and paths let you create import aliases:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}
import { formatDate } from "@utils/date"; // resolves to src/utils/date

Note: paths only affects type resolution. At runtime your bundler or a loader must apply the same aliases, or the import will fail.


Putting It Together

A pragmatic strict-plus config for a serious application:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",

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

    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    "sourceMap": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

When and Why to Enable Each Flag

Flag Enable when Why / what it prevents
noImplicitAny Always Silent loss of type safety
strictNullChecks Always The #1 source of runtime crashes
strictFunctionTypes Always Unsafe callback assignments
strictBindCallApply Always Wrong args to bind/call/apply
strictPropertyInitialization Class-heavy code Reading uninitialized fields
noImplicitThis Always this footguns
useUnknownInCatchVariables Always Assuming caught value is an Error
alwaysStrict Always Strict-mode-only runtime bugs
noUnusedLocals / Parameters Most projects Dead code, typos
noImplicitReturns Most projects Forgotten return branches
noFallthroughCasesInSwitch Most projects Missing break
exactOptionalPropertyTypes New code / careful teams undefined vs absent confusion
noUncheckedIndexedAccess New code / safety-critical Out-of-bounds array access

Middle Checklist

  • strict: true is on, and you can name every flag it enables.
  • Extra flags (noUnusedLocals, noImplicitReturns, noFallthroughCasesInSwitch) are on.
  • noUncheckedIndexedAccess is on for new safety-critical code (or there is a documented reason it is off).
  • You handle catch (e) as unknown and narrow before use.
  • You understand module (output format) vs moduleResolution (lookup algorithm).
  • paths aliases are mirrored in the runtime/bundler config.

Common Mistakes

Mistake 1: Disabling strictNullChecks to "make errors go away"

// Anti-pattern: silences the most valuable check
{ "compilerOptions": { "strict": true, "strictNullChecks": false } }

This re-introduces null bugs. Narrow your values instead.

Mistake 2: Expecting paths to work at runtime without a loader

import x from "@/foo"; // type-checks, but `node dist/index.js` cannot find "@/foo"

paths is compile-time only. Use a bundler, tsconfig-paths, or a package.json imports map for runtime.

strictNullChecks makes undefined a real type. exactOptionalPropertyTypes distinguishes missing from explicitly undefined. They are different; you generally want both.


Test

Multiple Choice

1. Which flag makes catch (e) give e: unknown?

  • A) strictNullChecks
  • B) useUnknownInCatchVariables
  • C) noImplicitAny
  • D) noUncheckedIndexedAccess
Answer **B)** — `useUnknownInCatchVariables` (on by default under `strict` since 4.4) types caught values as `unknown`, forcing a narrowing check.

2. arr[10] returns number | undefined because of which flag?

  • A) strictNullChecks
  • B) noImplicitReturns
  • C) noUncheckedIndexedAccess
  • D) exactOptionalPropertyTypes
Answer **C)** — `noUncheckedIndexedAccess` adds `| undefined` to indexed access results to reflect possible out-of-bounds reads.

True or False

3. strict: true enables noUncheckedIndexedAccess.

Answer **False** — `noUncheckedIndexedAccess` is **not** part of `strict`. You must enable it separately.

4. You can set strict: true and then disable one sub-flag.

Answer **True** — e.g. `"strict": true, "strictPropertyInitialization": false`. Individual flags override the umbrella.

What's the Output?

5. With exactOptionalPropertyTypes: true, does this compile?

interface O { x?: number }
const o: O = { x: undefined };
Answer No. Error: `{ x: undefined }` is not assignable because optional `x?: number` means *absent*, not *present-and-undefined*. You would need `x?: number | undefined`.

6. With noImplicitReturns: true, what is wrong here?

function f(n: number): string {
  if (n > 0) return "pos";
}
Answer "Not all code paths return a value." The `n <= 0` path falls off the end returning `undefined`, which is not `string`.

Summary

  • strict: true is an umbrella over eight flags; you can enable/disable each one individually.
  • strictNullChecks is the highest-value flag — it eliminates the most common crash class.
  • useUnknownInCatchVariables, strictFunctionTypes, strictPropertyInitialization, and friends each close a specific soundness hole.
  • Beyond strict, enable noUnusedLocals/Parameters, noImplicitReturns, noFallthroughCasesInSwitch, and ideally exactOptionalPropertyTypes and noUncheckedIndexedAccess.
  • module is the output format; moduleResolution is the lookup algorithm — keep them consistent.
  • paths aliases are compile-time only and need a runtime counterpart.

Next step: senior.md — recommended configs for new vs legacy projects, gradual hardening strategies, and performance flags.


Further Reading