TypeScript – tsconfig, the Flags That Matter

August 27, 20268 min readUpdated 9/5/2026

Most of tsconfig.json you set once and never look at again. A dozen or so options genuinely change what compiles, and those are worth understanding rather than copying.

This lesson works from the two real configurations in the pizza app, which are a useful pair because they disagree — and each one forbids something the other depends on.

strict, taken apart

"strict": true is the single most consequential line, and it is a switch for a family:

FlagWhat it stops
strictNullChecksnull/undefined being silently allowed in every type
noImplicitAnyan unannotated parameter quietly becoming any
strictFunctionTypesunsound callback parameter substitution
strictBindCallApplycall/apply with the wrong arguments
strictPropertyInitializationa declared class field never assigned
noImplicitThisthis being any in a loose function
useUnknownInCatchVariablescatch (e) giving you any
alwaysStrictoutput without "use strict"

Of those, strictNullChecks is most of the value. Without it a string silently includes null, and the runtime errors you adopted TypeScript to prevent are all still possible.

On a new project, turn the lot on and never think about it. On an existing one, enable them individually — that is why they exist as separate flags — and do noImplicitAny before strictNullChecks, because the second produces far more errors and they are easier to judge once the first is clean.

target and lib

Two settings people conflate. target decides the JavaScript syntax emitted; lib decides which APIs the checker believes exist.

    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "types": ["vite/client"],

target is set by what has to run your code. Modern browsers and Node 18+ handle ES2022 comfortably; a lower target makes the output larger and, as lesson 12 showed, breaks instanceof on subclassed built-ins.

lib defaults from target, and you set it explicitly when the two differ — a polyfilled environment, or code that must not touch the DOM. That is the interesting half of the pizza app's split:

    "target": "es2023",
    "lib": ["ES2023"],
    "types": ["node"],
    "skipLibCheck": true,

That is tsconfig.node.json, covering vite.config.ts. No DOM, because a build script has no document. With one shared config, document would autocomplete inside the build script and process.env inside a component — each of them a crash waiting to happen.

types controls which global @types packages are loaded. Omit it and every package in node_modules/@types is included globally, which is usually harmless and occasionally the reason two libraries' globals collide.

module and moduleResolution

Covered in lesson 17. The short version: a frontend wants "module": "esnext" with "moduleResolution": "bundler", and a Node service wants "nodenext" for both. Mixing them produces import errors that look like missing files.

noEmit and friends

    "noEmit": true,
    "jsx": "react-jsx",

noEmit makes tsc a checker only, which is the right division of labour when a bundler is compiling — lesson 2 covers why. If you are emitting, the ones you care about are outDir, declaration (emit .d.ts, required if you are publishing a library) and sourceMap.

jsx: "react-jsx" is the modern transform — no import React needed in every file.

The linting flags

These are not about correctness so much as hygiene, and the React app turns on four:

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true

noUnusedLocals and noUnusedParameters catch leftovers. Prefix a parameter with _ to say it is deliberately unused.

noFallthroughCasesInSwitch catches a case with no break — the companion to the never exhaustiveness check from lesson 10. One catches a case you forgot to write, the other a case you forgot to end.

erasableSyntaxOnly is the interesting one, and it is the reason this app has no enum and no parameter properties. It rejects every construct that has to generate code rather than merely be deleted:

  • enum
  • parameter properties — constructor(private api: ApiService)
  • namespace with runtime members
  • decorators

Turning it on guarantees your source can be run by a type-stripper — Node's built-in support, esbuild, or any other tool that deletes annotations without understanding them. It is a bet on where the tooling is going, and a reasonable one.

Where the two configs disagree

The Angular half sets the opposite:

    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "experimentalDecorators": true,

experimentalDecorators is exactly what erasableSyntaxOnly forbids. Angular's components, services, pipes and directives are all declared with decorators, so the framework cannot work without it — see the next lesson.

Neither app is misconfigured. One is a Vite project betting on type-stripping; the other is an Angular project whose compiler reads whole programs and generates code anyway. The configuration follows the toolchain, and this is worth internalising: a tsconfig is a description of your build, not a statement of taste.

Three of Angular's flags are worth stealing regardless. noImplicitOverride requires the override keyword when a subclass replaces a member, so a renamed base method does not silently orphan its override. noImplicitReturns catches a function with a return on some paths and not others. noPropertyAccessFromIndexSignature forces obj['key'] rather than obj.key for index-signature properties, which makes the "this key might not exist" cases visible.

Two more worth turning on

noUncheckedIndexedAccess makes every indexed read include undefined — the hole from lesson 5, where sizes[0] is typed as present on an empty array. Genuinely noisy, and genuinely correct.

exactOptionalPropertyTypes makes phone?: string mean "may be absent" rather than "may be absent or explicitly undefined", which is the distinction from lesson 6. Useful when you serialise objects and the difference reaches the wire.

Both are off by default because both break existing code. Both are worth it on something new.

skipLibCheck

Set in every config in this app, and in most projects:

    "skipLibCheck": true,

It stops TypeScript typechecking the .d.ts files in node_modules. That sounds reckless and is standard practice — your dependencies' declarations are not your problem, two libraries with conflicting global types would otherwise fail your build, and checking them is slow.

Your own code is still fully checked. The declarations are still used; they are just not verified against each other.

Project references

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

A root config that compiles nothing and points at the real ones, built together with tsc -b. This is the standard Vite layout, and the mechanism scales to a monorepo where each package is a referenced project and only the changed ones rebuild.

extends, and sharing configuration

A config can inherit from another, which is how the Angular app avoids repeating itself:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },

The base holds the strictness settings; the app config adds an output directory and narrows types. Merging is shallow and per-option — an object like paths is replaced wholesale, not merged — and relative paths in the base resolve relative to the base file, which is the detail that catches people out when extending a shared package.

You can also extend a published config, which is how @tsconfig/node22 and similar work:

  "extends": "@tsconfig/node22/tsconfig.json",

When the type checker gets slow

On a large project tsc eventually takes long enough to notice. Four things to check, roughly in order of payoff.

skipLibCheck, as above. If it is off, turn it on — this is usually the single biggest win and it costs nothing you wanted.

incremental writes a .tsbuildinfo file so a rebuild only does the changed work. composite implies it and is required for project references. Do add the build-info file to .gitignore — and be aware that a stale one can produce errors that do not match your source, which is worth knowing before you spend an hour on a phantom.

Check what is in the program. tsc --listFiles prints every file being checked, and the answer is sometimes a surprise — a wide include, a barrel that pulls in a directory, or a missing exclude.

Look for expensive types. tsc --diagnostics reports where the time went, and a large "check time" against a small program usually means a recursive conditional type — see lesson 15.

A starting point

For a new frontend, the React app's configuration is a good default as it stands. For a new Node service, the changes are "module": "nodenext", "moduleResolution": "nodenext", "noEmit": false, an outDir, and lib without DOM.

In both cases: strict on from the first commit. It is the one setting that is painful to adopt later and free to adopt now.

One last piece of advice on all of this. Do not copy a tsconfig from a blog post, including this one, without reading what each line does — a config is the most quietly consequential file in a TypeScript project, and a setting you did not intend can disable a check you were relying on for years without ever producing a message. tsc --showConfig prints the fully resolved configuration, including everything inherited through extends, which is the quickest way to find out what you are actually compiling with.

The corollary is that a config file you cannot explain is a liability. Every line in it was added by somebody for a reason, and the reasons are rarely written down.

And if you inherit a project with strict off, resist the urge to fix it in one commit. The order that works is the one from earlier — noImplicitAny, then strictNullChecks, then the rest — with each step merged separately so the diff stays reviewable and a regression can be traced to a flag rather than to a fortnight.

Two small conveniences worth knowing about while you are in there. allowJs plus checkJs are the migration pair from lesson 2. And resolveJsonModule lets you import config from './config.json' with the shape inferred from the file itself, which is one of the nicer small things the compiler does.

Where the file goes

One structural point that trips people up on a first project. tsc looks for a tsconfig.json in the current directory and then upward, and the directory containing it is the project root — every relative path inside is resolved from there, not from where you ran the command.

So in a monorepo, each package gets its own config, and a root config either references them all or holds only the settings they extend. Putting one config at the root and pointing include at every package works until two of them need different lib settings, which is usually about a fortnight.

Next

Decorators — what @Component and @Injectable are actually doing, and why experimentalDecorators is still called experimental.