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.
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.
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.noImplicitAny, strictNullChecks and seven more decide how much the compiler refuses. This is the main subject of the chapter; section 02 goes through them.
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.
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.
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.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.
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:
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.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:
Order | null and Order are interchangeable to the compiler. A null sitting where an Order is expected is not something it can see.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.
| Check | What it does | What is missed while it is off |
|---|---|---|
| strictFunctionTypes | Compares function parameter types more strictly when one function type is assigned to another | A function that only handles Cat is accepted where one handling any Animal is expected. TS2322 |
| strictBindCallApply | Checks the arguments of bind, call and apply against the original signature | greet.call(null, "boba", "3") passes although the second parameter is a number. TS2345 |
| strictPropertyInitialization | A declared class property must be assigned, in the declaration or in the constructor | A property you meant to assign later stays undefined until something reads it. TS2564 |
| strictBuiltinIteratorReturn | next() 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 |
| noImplicitThis | Reports this where its type cannot be inferred | this in a stray function is any, so every property read off it is unchecked. TS2683 |
| alwaysStrict | Parses your files in strict mode and adds "use strict" to non-module output | The loose-mode behaviors of older JavaScript, such as assigning to a variable that was never declared |
| useUnknownInCatchVariables | The variable bound by catch has type unknown, not any | e.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.
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:
sizes[3] is undefined at runtime. The type says string, and nothing contradicts it.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.
"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.
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:
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:
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:
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.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:
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.
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.
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.
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.
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: 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.
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:
(no tsconfig.json yet)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.
// @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:
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 it — tsc, 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.
Labs
A configuration file is not something you learn by reading. Run these in a terminal and in the Playground.
Quiz
Eight questions on what each option actually does.
With "strict": true, which check is not turned on?
findOrder is declared to return Order | null, and you write findOrder(id).total. What happens with strictNullChecks off?
What does the target option in tsconfig control?
You need to silence one error line during a migration. Why prefer @ts-expect-error over @ts-ignore?
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 ____.
You are starting a backend project whose compiled output runs directly on Node. How should module be set?
Which of these statements about tsconfig are true? (multiple)
You inherit a 900-line JavaScript ordering system, and the owner says it cannot go offline for a day. What is the first step?
tsconfig.jsonis read by bothtscand 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: trueturns on nine checks at once:noImplicitAny,strictNullChecks,strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,strictBuiltinIteratorReturn,noImplicitThis,useUnknownInCatchVariables,alwaysStrict. Enable it on a new project's first day.strictis not every check.noUncheckedIndexedAccess(an index read becomesT | undefined) andexactOptionalPropertyTypesare outside it and must be enabled separately. Both needstrictNullChecksto do anything.targetsets the emitted syntax level and a defaultlib;libdecides which type declarations exist and adds no polyfill.moduleis the format written out,moduleResolutionis how imports are found. Node directly:module: nodenext. Behind a bundler:esnext + moduleResolution: bundler.tscchecks and writes one.jsper input file; it does not bundle, and a type error does not stop it writing output unlessnoEmitOnErroris set. If a bundler orswcproduces your JavaScript andtsc --noEmitis only the check, the emit options do not affect what ships.- Migrate JavaScript incrementally:
allowJs, thencheckJsor@ts-checkplus JSDoc types, then one file at a time to.ts, then thestrictmembers one by one. Silence a line with@ts-expect-error, not@ts-ignore: it reportsTS2578once the error is gone, so the note cannot be forgotten.