# tsconfig and Strictness

> Source: https://learn-typescript.org/tsconfig-and-strictness/
> Part of Learn TypeScript, free to read.

`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

```json 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:

**`strictNullChecks`** — `null` 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.

:::warn `@ts-ignore` is not the way to adopt strictness
Use `@ts-expect-error` instead. It errors if the line stops being wrong, so it cannot rot silently — and a codebase full of `@ts-expect-error` at least tells you where the debt is. Ban `@ts-ignore` in lint.
:::

## Checking is not building

```json 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" }] }
```

```bash
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.
