TSer/10 · tsconfig and strict mode
CHAPTER 10 · The strict family

tsconfig and strict mode

The same file can pass in one project and fail in another. The code is identical; the rules are not. Those rules live in tsconfig.json, and strict is the part that decides how much the compiler will check.

§01

tsconfig.json: the file tsc and your editor both read

Whether a piece of code is an error is decided partly by the code and partly by this file. It is worth reading yours.

One file, two readers

tsconfig.json is read by two programs. One is tsc, the command you run in a terminal or in CI. The other is the type service — the background process your editor uses to draw the red underlines as you type. Both read the same file, which is why the underline in your editor and the failure in CI say the same thing.

It also decides how strictly the code is checked, which is close to a difficulty setting: the same code, different rules, a different answer. Create the file with one command.

Create the file
1npx tsc --init
2# Created a new tsconfig.json
3#
4# You can learn more at https://aka.ms/tsconfig
tsconfig.json · what tsc --init writes (abridged)
1{
2 "compilerOptions": {
3 // Environment Settings
4 "module": "nodenext",
5 "target": "esnext",
6 "types": [],
7
8 // Stricter Typechecking Options
9 "noUncheckedIndexedAccess": true,
10 "exactOptionalPropertyTypes": true,
11
12 // Style Options
13 // "noImplicitReturns": true,
14 // "noImplicitOverride": true,
15 // "noUnusedLocals": true,
16
17 // Recommended Options
18 "strict": true,
19 "verbatimModuleSyntax": true,
20 "isolatedModules": true,
21 "skipLibCheck": true,
22 }
23}
Two things to notice. First, the trailing comma and the comments: tsconfig.json is JSONC, which is JSON that allows comments, so you can record why an option is set. Second, the section names. strict sits under "Recommended Options", while noUncheckedIndexedAccess and exactOptionalPropertyTypes sit in a separate section of their own. That separation is a fact about the language, and section 03 is about it.
Group 1 · how much is checked
the strict family

noImplicitAny, strictNullChecks and seven more decide how much the compiler refuses. This is the main subject of the chapter; section 02 goes through them.

Group 2 · what comes out
target / module

Which generation of JavaScript syntax is emitted, in which module format, into which directory. Section 04 covers these — including when they do not matter at all.

Group 3 · which files
include / paths

Which files TypeScript is responsible for (include, exclude, files) and how import path aliases are resolved (paths).

What tsc does, and what it does not do

tsc does two separate jobs: it checks the types, and it writes JavaScript — one output file per input file. It is not a bundler, and it never merges your modules into one file. Those two jobs are also independent: a type error does not stop the JavaScript from being written, unless you also set noEmitOnError.

Two jobs, separable
1# tsc checks, and writes one .js file per input .ts file.
2tsc # src/order.ts -> dist/order.js
3 # src/menu.ts -> dist/menu.js
4
5# A type error does not stop the JavaScript from being written.
6tsc # error TS2322: ... and dist/order.js exists anyway
7tsc --noEmitOnError # now nothing is written while errors remain
8
9# Check only, write nothing. This is how most projects use tsc.
10tsc --noEmit
11
12# tsc is not a bundler. It never merges files into one.
13tsc --outFile all.js
14# error TS6082: Only 'amd' and 'system' modules are
15# supported alongside --outFile.
Most projects today split the two jobs. A bundler, or swc or esbuild, strips the types and produces the JavaScript, and tsc --noEmit is used purely as the type check. When a project is set up that way, the emit options in tsconfig do not affect what ships — the other tool's settings do. Worth checking which case your project is in before you spend time tuning target.
§02

The strict family: one switch, nine checks

strict: true is not one check. It is a name for a group of nine, turned on together. Use the panel to turn them on one at a time and watch what the compiler starts to see.

In TypeScript 5.9, "strict": true turns on exactly these nine: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, strictBuiltinIteratorReturn, noImplicitThis, useUnknownInCatchVariables, and alwaysStrict. The list has grown over time — strictBuiltinIteratorReturn joined in TypeScript 5.6 — so if you need the exact membership for a specific version, check that version's reference rather than a memorised list. Several options that sound like members are not: noImplicitOverride, noUnusedLocals and noImplicitReturns all sit in the template's separate "Style Options" section, switched off.

One piece of code. The compiler sees exactly as many bugs as the switches allow.
order.tsfound 0 / 5
1// Legacy ordering code. "It works, do not touch it."
2function total(items) {
3 let sum = 0;
4 for (const it of items) sum += it.price;
5 return sum;
6}
7
8const order = findOrder("A-101"); // declared: Order | null
9console.log(order.total);
10
11const sizes = ["small", "medium", "large"];
12console.log(sizes[3].toUpperCase());
13
14class Barista {
15 name: string; // "I will assign it later"
16}
17
18try {
19 submit(order);
20} catch (e) {
21 console.log(e.message);
22}
✓ 0 errors — the compiler has nothing to say. Note what that does and does not mean: no error is not the same as no bug. None of the checks that are on can reach these lines.

Two of the nine deserve a closer look. The first is noImplicitAny. When the compiler cannot work out a type on its own, this check makes that an error instead of quietly using any:

Off · nothing is reported
1function total(items) {
2 // items is treated as any,
3 // so nothing inside is checked
4 let sum = 0;
5 for (const it of items) sum += it.pirce;
6 return sum; // "pirce" is a typo. No error.
7}
8
9total(order.items); // NaN, discovered in production
No inferable type means any, and any turns the checks off for everything derived from it. Misspelled fields and wrong arguments all pass. Those lines are TypeScript in name only.
On · you have to say what it is
1function total(items) {
2// TS7006: Parameter 'items'
3// implicitly has an 'any' type.
4
5// So you write the type down:
6function total2(items: OrderItem[]) {
7 let sum = 0;
8 for (const it of items) sum += it.pirce;
9 // TS2551: Property 'pirce' does not exist
10 // on type 'OrderItem'. Did you mean 'price'?
11 return sum;
12}
The rule is: if the compiler cannot infer a type, you have to write one. Once the type is written down, the typo pirce becomes an error on the line where it is written.

The second is strictNullChecks, and it is the member that changes the most existing code. With it off, null and undefined are assignable to every type, so "this value may be missing" cannot be written down at all:

Off · null fits everywhere
1// findOrder returns Order | null
2const order = findOrder("A-101");
3
4console.log(order.total);
5// Compiles with no error.
6// Then, one night in production:
7// TypeError: Cannot read
8// properties of null
With the flag off, Order | null and Order are interchangeable to the compiler. A null sitting where an Order is expected is not something it can see.
On · check before you use it
1const order = findOrder("A-101");
2
3console.log(order.total);
4// TS18047: 'order' is possibly 'null'.
5
6if (order !== null) {
7 console.log(order.total); // allowed
8}
9// This is narrowing, from chapter 03.
"May be missing" is now part of the type, so reading a property requires a check first. The TypeError at 2 a.m. becomes a red underline this afternoon.

The billion-dollar mistake

Tony Hoare introduced the null reference in 1965 and apologised for it publicly in 2009, calling it his "billion-dollar mistake". The problem is not the value itself; it is that in most languages every type silently includes it, so the compiler cannot tell you where a check is missing. strictNullChecks is TypeScript's answer: if a value may be missing, the type says so, and you check it before use. If you can only enable one member of the family, enable this one.

The remaining seven, in one table. The column to read is the last one: what goes unnoticed while the check is off.

CheckWhat it doesWhat is missed while it is off
strictFunctionTypesCompares function parameter types more strictly when one function type is assigned to anotherA function that only handles Cat is accepted where one handling any Animal is expected. TS2322
strictBindCallApplyChecks the arguments of bind, call and apply against the original signaturegreet.call(null, "boba", "3") passes although the second parameter is a number. TS2345
strictPropertyInitializationA declared class property must be assigned, in the declaration or in the constructorA property you meant to assign later stays undefined until something reads it. TS2564
strictBuiltinIteratorReturnnext() on a built-in iterator returns IteratorResult<T, undefined> instead of <T, any>After r.done, r.value is any, so any call on it is allowed. TS18048
noImplicitThisReports this where its type cannot be inferredthis in a stray function is any, so every property read off it is unchecked. TS2683
alwaysStrictParses your files in strict mode and adds "use strict" to non-module outputThe loose-mode behaviors of older JavaScript, such as assigning to a variable that was never declared
useUnknownInCatchVariablesThe variable bound by catch has type unknown, not anye.message is used directly, but what was thrown need not be an Error — it can be a string. TS18046

Some of these depend on strictNullChecks

strictPropertyInitialization cannot work on its own. "Never assigned" means "the value is undefined", and without strictNullChecks that sentence has no meaning. The compiler does not merely ignore the combination — it refuses it: TS5052: Option 'strictPropertyInitialization' cannot be specified without specifying option 'strictNullChecks'.

This is one reason the recommendation is to write strict: true rather than list the members individually: they are designed to work together, and picking a subset is how you end up with a flag that is on and doing nothing.

§03

strict is not every check: the two that stay outside

Two checks worth having are not part of strict: true. You enable them yourself, and tsc --init now does it for you in a new project.

The first is noUncheckedIndexedAccess. Reading arr[0] gives you the element type, and the compiler assumes the index is in range. With the whole strict family on, the code on the left still compiles:

Off · the index is trusted
1const sizes =
2 ["small", "medium", "large"];
3
4sizes[3].toUpperCase();
5// The type of sizes[3] is string.
6// Compiles with no error, even
7// with the whole strict family on.
8// At runtime: TypeError.
The array has three elements, so sizes[3] is undefined at runtime. The type says string, and nothing contradicts it.
On · every index read may be missing
1const size: string = sizes[3];
2// TS2322: Type 'string | undefined'
3// is not assignable to type 'string'.
4
5sizes[3].toUpperCase();
6// TS2532: Object is possibly 'undefined'.
7
8sizes[3]?.toUpperCase(); // allowed
Every index read becomes T | undefined, so you narrow it before use. Note the dependency: this flag needs strictNullChecks. Without it, string | undefined collapses back to string and the flag has no effect at all.

The second is exactOptionalPropertyTypes. It separates two things that an optional property normally mixes together: the key being absent, and the key being present with the value undefined.

exactOptionalPropertyTypes
1interface Order {
2 topping?: string; // optional: the key may be absent
3}
4
5// Off: this is allowed. undefined slips in as a value.
6const o: Order = { topping: undefined };
7// On: TS2375: Type '{ topping: undefined; }' is not
8// assignable to type 'Order' with
9// 'exactOptionalPropertyTypes: true'.
10
11// The payoff is that a runtime check now agrees
12// with the type:
13if ("topping" in o) {
14 const t: string = o.topping;
15 // Off: TS2322 — the type is still string | undefined.
16 // On: allowed. If the key is present, so is the value.
17}
The distinction sounds academic until you write a runtime check. "topping" in o and Object.keys(o) ask whether the key is there. Only under this flag does the answer line up with the type, so narrowing on in gives you string rather than string | undefined.

Why they are not in strict

Both report on patterns that appear everywhere in existing code. noUncheckedIndexedAccess has something to say about every index read, so turning it on in a large codebase can produce hundreds of errors that are individually small and collectively a project. Keeping them out of strict means an existing project can adopt strict without also taking that on.

A new project has no such backlog, and tsc --init reflects that: it writes both options as true. Also note that exactOptionalPropertyTypes has the same hard dependency as strictPropertyInitialization — set it without strictNullChecks and the compiler reports TS5052 and refuses to run.

§04

The output side: target, lib, module

These options describe the environment your code will run in. They are easy to confuse with each other, and each one answers a different question.

target decides which generation of JavaScript syntax the compiler writes. Anything newer than the target gets rewritten into an older equivalent. Switch it and compare:

The same TypeScript. target decides which generation of JavaScript comes out.
What you write · order-utils.ts
1const label = (n: number, unit = "cup") => `${n} ${unit}`;
2
3const [first, ...rest] = ["boba", "jelly"];
4
5class Cup {
6 constructor(public size: string) {}
7}
What tsc writes · target: es2022
1// target: "es2022" — the syntax is kept as written.
2// Only the types are removed.
3const label = (n, unit = "cup") => `${n} ${unit}`;
4const [first, ...rest] = ["boba", "jelly"];
5class Cup {
6 size;
7 constructor(size) {
8 this.size = size;
9 }
10}
Every form in the source already exists in ES2022, so tsc removes the types and changes nothing else. The output is small and stays readable next to the source.

lib is the option people mistake for target. target controls the syntax that comes out; lib controls which type declarations exist while the compiler checks — whether Promise, Map, Array.prototype.at and document are known names at all. Setting target also picks a default lib, which is why the two feel like one option until you need them apart:

target and lib answer different questions
1// tsconfig: { "target": "es5" } — lib not set, so it
2// defaults to es5.
3const ready: Promise<number> = Promise.resolve(1);
4// TS2585: 'Promise' only refers to a type, but is being
5// used as a value here. Do you need to change your target
6// library? Try changing the 'lib' compiler option to
7// es2015 or later.
8
9const last = [1, 2, 3].at(-1);
10// TS2550: Property 'at' does not exist on type 'number[]'.
11
12// tsconfig: { "target": "es5", "lib": ["es2022"] }
13// Both errors disappear. The emitted JavaScript is
14// byte for byte the same as before: lib does not
15// affect emit, and it does not add a polyfill.
This is the case that makes the split obvious: you want Promise to have types while still emitting ES5 syntax, so you set the two separately. But be careful what you are saying. lib is a claim about the runtime, and the compiler takes your word for it. It does not add a polyfill. Claim es2022, then run on an engine with no Promise, and the failure happens at runtime with nothing reported at compile time.

module and moduleResolution are also two settings, not one. module is the module format the compiler writes out. moduleResolution is the algorithm it uses to find the file behind import "./util". In practice you pick a pair, and which pair depends on who consumes the output:

Output that Node runs directly
1{
2 "compilerOptions": {
3 "module": "nodenext",
4 "target": "es2022",
5 "strict": true
6 }
7}
nodenext follows Node's own rules, including the type and exports fields in package.json. It also fixes moduleResolution for you — set that to anything else and you get TS5109: Option 'moduleResolution' must be set to 'NodeNext', so leave it out.
Output a bundler consumes
1{
2 "compilerOptions": {
3 "module": "esnext",
4 "moduleResolution": "bundler",
5 "target": "es2022",
6 "strict": true,
7 "noEmit": true
8 }
9}
bundler matches how Vite and esbuild actually resolve imports, so the compiler agrees with the tool that will do the work. It requires module to be esnext, preserve, or es2015 or later — otherwise TS5095. And since the bundler produces the JavaScript, noEmit is usually right here.

The rest of the output group, one line each:

outDir / rootDir

Where the output goes (dist) and where the sources are (src). Without outDir, each .js lands next to its .ts, which makes the source tree hard to read and easy to commit by accident.

sourceMap

Writes a map from the output back to the source, so a debugger and a stack trace can point at your .ts line instead of the emitted line. Turn it on.

verbatimModuleSyntax

An import used only as a type must be written import type, or you get TS1484. The compiler then never has to guess whether an import can be deleted — it deletes exactly the type ones. Chapter 09 covers this.

isolatedModules

Rejects code that cannot be compiled one file at a time. For example, re-exporting a type without export type gives TS1205. Necessary when a single-file transpiler such as Babel, swc or esbuild does the emitting, because it never sees your other files.

esModuleInterop

Lets you write import legacy from "legacy" for a CommonJS module that uses export =. Without it: TS1259: Module can only be default-imported using the 'esModuleInterop' flag. It also changes the emitted interop code, so it is on by default in modern setups.

noEmit / noEmitOnError

noEmit: check and write nothing — the right setting when another tool produces the JavaScript. noEmitOnError: write output only when there are no errors. They are different questions, and the default for both is off.

skipLibCheck, and why nearly everyone turns it on

skipLibCheck: true skips type checking inside .d.ts files — all of them, including any you wrote yourself. It does not skip your .ts code, and it does not stop those declarations from being used to check your code. What it skips is checking the declaration files against themselves.

Strictly this loses information: a library's declarations really can be wrong. In practice those errors are not yours to fix, and two libraries whose global declarations disagree can otherwise fail your build for a reason that has nothing to do with your code. The common choice is to turn it on and spend the checking on your own files.

One related option worth knowing: erasableSyntaxOnly (TypeScript 5.8 and later) rejects any syntax that has a runtime effect and therefore cannot be removed by simply stripping types — enum, namespace with a body, and constructor parameter properties. The error is TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled. It matters if you want your files to be runnable by a tool that only strips types, such as recent versions of Node.

§05

In practice: migrating a JavaScript project

Three JavaScript files, 900 lines, nobody has changed them in years. The system cannot stop running while you work.

The approach that fails is doing it all at once: rename every file, turn strict all the way up, and face four hundred errors with no way to ship a partial fix. The approach that works is incremental: the system runs at every step, and every step is stricter than the one before it. Step through it:

Migrating a JavaScript ordering system, one step per frame
Day 0 · where you start✓ 0 errors
order.jsnot checked
menu.jsnot checked
boss.jsnot checked
tsconfig.json(no tsconfig.json yet)
Three JavaScript files, 900 lines, one comment: "do not touch". Zero errors — not because there are no bugs, but because nothing is checking.
1 / 6

The day-2 step does not have to touch tsconfig at all. Two comments at the top of one .js file are enough: one to turn the checker on, one to give it a type to compare against.

boss.js · two comments, no rename
1// @ts-check
2/** @type {{ name: string, price: number }[]} */
3const menu = loadMenu();
4
5menu.forEach((it) => {
6 console.log(it.name, it.pirce);
7 // ~~~~~
8 // TS2551: Property 'pirce' does not exist on type
9 // '{ name: string; price: number; }'.
10 // Did you mean 'price'?
11});
Both lines matter. // @ts-check turns the checker on for this file. The @type comment gives menu a type — without it, menu is any, and reading it.pirce off an any is allowed, so the typo would go unreported. JSDoc types are a bridge for the migration, not the destination: when the file becomes .ts, they turn into ordinary type annotations.

Every migration has errors you cannot fix today. There are two comments for silencing one line, and they behave very differently over time:

@ts-ignore · silent forever
1// @ts-ignore
2legacy.doSomething(order);
3
4// If the error on the line below is
5// fixed one day, @ts-ignore says
6// nothing. It stays silent forever,
7// and the note stays in the file.
It hides the error, and it also hides the good news that the error is gone.
@ts-expect-error · reports itself
1// @ts-expect-error legacy has no types yet
2legacy.doSomething(order);
3
4// When the line below stops reporting,
5// this comment reports instead:
6// TS2578: Unused '@ts-expect-error'
7// directive.
8// So you find out, and delete it.
It states "I expect an error here". When the error goes, the claim becomes false and the comment reports. Use this one during a migration, and write the reason after it.
§06

Four common mistakes

Most configuration problems are not about not knowing the options. They are about assuming something that is not true.

1 · "New project — leave strict off for now, we will turn it on later"

This has the order backwards. The cost of strict grows with the amount of code. On day one it is zero: each error is corrected in the minute you write the line. Three months later it is several hundred errors across code you no longer remember, plus the temptation to leave it off permanently. Enabling strict gradually is a sound strategy for existing code. For new code it is just a postponed bill.

2 · Using @ts-ignore as a habit

Each @ts-ignore is one line the checker no longer reads. Occasionally that is a reasonable trade. As a habit it removes the type system a line at a time, and nothing records where: the project looks checked, and is full of exemptions nobody can list. When you genuinely cannot fix something, use @ts-expect-error with the reason written next to it, so the note removes itself once the problem is gone.

3 · "target should always be the newest"

target is not a version to compete on. It is a statement about the environment your output will run in. If that environment is a browser locked to an old version inside a company network, setting esnext delivers a SyntaxError to a real user. The compiler cannot warn you, because only you know where the output runs. Answer "who runs this?" first.

The other half of this mistake is spending time on target in a project where a bundler produces the JavaScript. In that setup the bundler's own target decides what ships, and tsconfig's target only affects type checking and any files tsc itself emits.

4 · Looking for the one correct tsconfig

There is no single correct configuration, and copying one from another project usually brings settings that describe that project's environment rather than yours. What is worth copying is the reasoning. Two questions decide most of the file: who runs the output — Node directly, or a bundler — and who produces ittsc, or another tool with tsc --noEmit as the check.

Two things are safe to say in general. Turn on strict — for new code there is no argument against it. And read the file you have, rather than inheriting it: every option in it is a claim about your project, and some of those claims may no longer be true.

§07

Labs

A configuration file is not something you learn by reading. Run these in a terminal and in the Playground.

§08

Quiz

Eight questions on what each option actually does.

QUESTION 01 / 8

With "strict": true, which check is not turned on?

QUESTION 02 / 8

findOrder is declared to return Order | null, and you write findOrder(id).total. What happens with strictNullChecks off?

QUESTION 03 / 8

What does the target option in tsconfig control?

QUESTION 04 / 8

You need to silence one error line during a migration. Why prefer @ts-expect-error over @ts-ignore?

QUESTION 05 / 8

One compiler option changes the type of an array index read from T to T | undefined, so an out-of-range read becomes a compile error. It is not part of strict. Its name is ____.

QUESTION 06 / 8

You are starting a backend project whose compiled output runs directly on Node. How should module be set?

QUESTION 07 / 8

Which of these statements about tsconfig are true? (multiple)

QUESTION 08 / 8

You inherit a 900-line JavaScript ordering system, and the owner says it cannot go offline for a day. What is the first step?

What to take away from this chapter
  • tsconfig.json is read by both tsc and your editor's type service, so the same code gives the same answer in both. Its options fall into three groups: how much is checked, what the output looks like, and which files are included.
  • strict: true turns on nine checks at once: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, strictBuiltinIteratorReturn, noImplicitThis, useUnknownInCatchVariables, alwaysStrict. Enable it on a new project's first day.
  • strict is not every check. noUncheckedIndexedAccess (an index read becomes T | undefined) and exactOptionalPropertyTypes are outside it and must be enabled separately. Both need strictNullChecks to do anything.
  • target sets the emitted syntax level and a default lib; lib decides which type declarations exist and adds no polyfill. module is the format written out, moduleResolution is how imports are found. Node directly: module: nodenext. Behind a bundler: esnext + moduleResolution: bundler.
  • tsc checks and writes one .js per input file; it does not bundle, and a type error does not stop it writing output unless noEmitOnError is set. If a bundler or swc produces your JavaScript and tsc --noEmit is only the check, the emit options do not affect what ships.
  • Migrate JavaScript incrementally: allowJs, then checkJs or @ts-check plus JSDoc types, then one file at a time to .ts, then the strict members one by one. Silence a line with @ts-expect-error, not @ts-ignore: it reports TS2578 once the error is gone, so the note cannot be forgotten.