Foundations Updated 2026-09 View as Markdown

tsconfig and Strictness

The most important file in a TypeScript project. Four flags beyond strict do most of the real bug-catching, and all four are off by default.

tsconfig.json decides how much help the compiler gives you. The defaults are deliberately permissive so that existing JavaScript can be adopted gradually — which means a fresh project inherits a checker doing far less than it could.

A config worth starting from#

tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,

    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["ES2023"],

    "skipLibCheck": true,
    "incremental": true,
    "noEmit": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

What strict turns on#

strict: true is a bundle. The two that matter most:

strictNullChecksnull and undefined stop being assignable to everything.

typescript
function greet(name: string) { return `Hi ${name}` }
greet(null);            // error, as it should be

let user: User | null = findUser();
user.email;             // error: possibly null
if (user) user.email;   // fine

Without this flag every value is secretly nullable and the type system is lying to you.

noImplicitAny — a parameter with no annotation and no inferrable type is an error rather than a silent any.

Also included: strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, useUnknownInCatchVariables, alwaysStrict.

The four beyond strict#

These are not in strict and each catches a distinct class of real bug.

noUncheckedIndexedAccess — the highest-value flag in the whole file.

typescript
const first = items[0];       // without: User. with: User | undefined
first.email;                  // now an error you must handle

Array and record access is where code assumes presence most often, and by default TypeScript is unsound here for convenience. Turning it on finds real bugs immediately.

exactOptionalPropertyTypes — distinguishes "absent" from "present and undefined".

typescript
type Opts = { retries?: number };
const o: Opts = { retries: undefined };   // error with the flag on

Without it, { retries: undefined } and {} are interchangeable, which breaks code that uses "retries" in opts.

noImplicitOverride — requires the override keyword when a subclass replaces a base method, so renaming the base method does not silently orphan the override.

noFallthroughCasesInSwitch — catches a missing break.

Adopting on an existing codebase#

Turning everything on at once produces hundreds of errors and gets reverted. Do it in order, one flag per pull request:

  1. strict: true with strictNullChecks: false — get the easy wins first.
  2. Turn on strictNullChecks. This is the big one; expect the most errors and the most real bugs found.
  3. noUncheckedIndexedAccess, then the rest.

For a very large codebase, scope by directory:

json
{
  "compilerOptions": { "strict": true },
  "include": ["src/**/*"],
  "exclude": ["src/legacy/**"]
}

Then a second config that checks src/legacy loosely, and you delete entries from exclude as the debt is paid. A visible shrinking list beats an invisible surrender.

Checking is not building#

package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsup src/index.ts",
    "check": "npm run typecheck && npm run lint && npm run test"
  }
}

Transpilers like esbuild and swc strip types without checking them, which is why they are fast — they never build a type graph. That makes them ideal for your dev server and your build, with tsc --noEmit as the separate correctness gate in CI.

The practical consequence: your code running does not mean it type-checks. If tsc --noEmit is not in your CI, you do not have type safety, you have syntax highlighting.

Keeping it fast#

json
{ "compilerOptions": { "incremental": true, "skipLibCheck": true } }

skipLibCheck skips checking .d.ts files in dependencies. Nearly everyone enables it; the trade is that a broken third-party definition passes silently.

For a monorepo, project references let each package be checked once and reused:

json
{ "files": [], "references": [{ "path": "./packages/api" }, { "path": "./packages/shared" }] }
shell
tsc --build            # rebuilds only what changed, in dependency order

This matters more than it sounds: a slow tsc drops out of your edit loop, and a type checker you do not run is not checking anything.

Exercise#

json
// Write a tsconfig.json for a Node 22 library that:
//   - is as strict as this lesson recommends
//   - emits declaration files to ./dist
//   - uses nodenext module resolution
//   - only includes ./src

Common questions#

Which single flag should I turn on first?#

strict: true. After that, noUncheckedIndexedAccess — it finds the most real bugs of anything not already in strict, because assuming array elements exist is one of the most common mistakes in both hand-written and generated code.

Is skipLibCheck: true safe?#

It is the pragmatic default — without it one broken definition in a transitive dependency blocks your build for reasons that are not your fault. The cost is that errors in the type definitions you rely on go unreported. Turn it off occasionally to see what it says.

Do I still need tsc if my bundler handles TypeScript?#

Yes. Bundlers strip types; they do not check them. Keep tsc --noEmit in CI or you are shipping unchecked code that merely happens to compile.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.