Lesson 1 of 8
tsconfig & the strict Family
"Programs must be written for people to read, and only incidentally for machines to execute." — Harold Abelson
You have spent five parts learning the language: types, unions, generics, the shape of data. Welcome to Part VI, where we stop studying TypeScript in the abstract and start running it in anger — in real repositories, with real teammates, and a real build. And the very first thing every real project does, before a single line of .ts is typed, is answer one question: how strict do we want the compiler to be? That answer lives in one file — tsconfig.json — and it is the highest-leverage file you will ever configure. Set it well and the compiler becomes a relentless, tireless reviewer that catches the bugs your teammates miss. Set it lazily and TypeScript quietly degrades into JavaScript with extra punctuation.
TypeScript's safety is not a fixed property of the language — it's a dial you set. Where's the dial, which notches actually matter, and why should you turn it almost all the way up on day one?
Core Concepts
tsconfig.json is the project's control panel. Its presence is what tells tsc — and your editor, and your bundler, and your teammates — "here begins a TypeScript project, and here are the rules." Generate a starting one with tsc --init, which writes a heavily commented file you then prune to taste:
npm i -D typescript # the compiler, as a dev dependency
npx tsc --init # scaffold a tsconfig.json
npx tsc # type-check + emit, obeying tsconfig
npx tsc --noEmit # type-check only (let a bundler emit)
npx tsc --watch # re-check on every saveThe anatomy of the file
Four top-level keys carry almost all the weight:
compilerOptions— the big object where nearly everything lives: which JS to emit, how strict to be, where output goes.include— glob patterns for the files the project owns, e.g.["src/**/*"]. If you omit it,tscgrabs every.tsunder the config's folder, which is rarely what you want.exclude— patterns carved back out ofinclude(it does not apply tofiles). Defaults tonode_modules,bower_components, and youroutDir— you'll add things likedistand test fixtures.files— an explicit, unglobbed list of exact paths. Handy for tiny projects; almost everyone reaches forincludeinstead.
And the key that scales teams: extends. One base config, many projects inheriting it — the TypeScript equivalent of a shared ESLint config. A monorepo keeps a tsconfig.base.json at the root, and each package writes {"extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" }}. Child options are shallow-merged over the parent, so a package overrides only what it needs.
target, lib, and module
Two options decide what JavaScript world you're compiling into, and they're often confused:
target— the JavaScript versiontscemits."ES2022"means "you may keep modern syntax like??, optional chaining, and top-level class fields in the output; don't down-level them." Pick the oldest runtime you must support.lib— the ambient type declarations available to your code, with no runtime effect."DOM"gives youdocumentandfetch;"ES2022"gives youArray.prototype.atandObject.hasOwn. A Node backend drops"DOM"; a browser app keeps it. If you omitlib, it's inferred fromtarget.
module and moduleResolution control how import/export are emitted and located. For modern work you'll mostly write "module": "NodeNext" (Node's real ESM/CJS rules) or "bundler" (you're handing modules to Vite/esbuild/webpack). That's a whole chapter of its own — the next one — so we'll leave it there.
target and lib are the difference between the language you speak and the dictionary you're allowed to cite. target sets the grammar of the sentences you emit — modern slang or careful formal prose an older listener can follow. lib is the reference books on the shelf: reach for a word that isn't in your editions (Array.at without "ES2022") and the compiler says "that's not in any dictionary you own." They move together but they are not the same thing.
strict: true — the master switch
Here is the single most important line in any tsconfig.json:
{ "compilerOptions": { "strict": true } }strict is not one check; it is an umbrella that flips on a whole family at once. Turn it on and every future TypeScript release can add a new strictness member under the same flag, so strict: true keeps getting stricter as you upgrade — a feature, not a bug. The important members:
Flag (folded into strict) |
What it catches |
|---|---|
strictNullChecks |
The big one. null and undefined are no longer silently assignable to every type — you must handle the empty case. |
noImplicitAny |
A parameter or variable the compiler can't infer becomes an error, not a silent any escape hatch. |
strictFunctionTypes |
Function parameters are checked contravariantly — you can't pass a handler that expects more than it's given. |
strictPropertyInitialization |
A class field typed non-optional must actually be assigned in the constructor. |
strictBindCallApply |
.bind, .call, .apply are type-checked against the real signature instead of accepting anything. |
noImplicitThis |
Using this where its type is an implicit any is an error. |
useUnknownInCatchVariables |
catch (e) gives you unknown, not any — you must narrow before you touch the error. |
alwaysStrict |
Parses in JS strict mode and emits "use strict". |
strictNullChecks is the one that changes how you think. Without it, undefined inhabits every type and the billion-dollar mistake ships silently. With it, a value that might be missing has the type T | undefined, and the compiler refuses to let you use it until you've proven it's there. That single check catches more real production bugs than any other line in this file.
Seeing strict earn its keep
Here Array.prototype.find returns Product | undefined — because the item might not exist. Under strictNullChecks (on, via strict), the compiler forces you to reckon with the undefined case before you dereference:
Uncomment the marked line and the type panel lights up red with exactly 'cheapest' is possibly 'undefined'. — TypeScript has caught, at author time, the TypeError: Cannot read properties of undefined that would have crashed at runtime. The if (cheapest) guard narrows Product | undefined down to Product inside the block, and the error vanishes. That is the loop you'll live in: the compiler names the gap, you handle it, the code gets safer.
strictNullChecks arrived in TypeScript 2.0 (2016) and it was, quietly, one of the most consequential releases the project ever shipped. Before it, TypeScript had types but not honesty — undefined lurked inside every type, so the compiler's guarantees stopped exactly where real bugs began. Anders Hejlsberg's team was directly answering Tony Hoare's famous confession that his 1965 null reference was a "billion-dollar mistake." The strict umbrella came later, in TS 2.3, precisely so that new checks could be adopted en masse instead of one forgotten flag at a time.
Beyond strict — flags worth adding
strict: true is the floor, not the ceiling. Several excellent checks live outside the umbrella because they can be noisy — but on a new codebase, before habits form, they're nearly free to adopt:
noUncheckedIndexedAccess— the one I'd add first. It makesarr[i]andrecord[key]returnT | undefined, because indexing can always miss. This is honest in a way plainstrictisn't:stricthappily letsconst x = arr[0]be typedTeven for an empty array. Adopt it and off-by-one and missing-key bugs surface at compile time.noImplicitOverride— a subclass method that overrides a parent's must sayoverride. Rename the parent method and the compiler flags the now-orphaned child instead of silently keeping both.noFallthroughCasesInSwitch— acasethat does work and forgetsbreakbecomes an error. The classic C-era footgun, closed.noUnusedLocals/noUnusedParameters— dead variables and unused params become errors (prefix a param with_to say "intentionally ignored").exactOptionalPropertyTypes— distinguishes "property absent" from "property present and set toundefined." Precise, occasionally pedantic.verbatimModuleSyntax— keeps yourimport/exportstatements exactly as written (dropping onlyimport type), removing guesswork about what's elided. More in the next chapter.
How seasoned teams actually treat this file:
• They never turn strict off to "fix" errors. Each error is a real bug or a real gap; suppressing the messenger just moves the failure to production.
• They set noUncheckedIndexedAccess on greenfield projects and skip it on legacy ones — retrofitting it onto a huge codebase can mean thousands of new errors, so they weigh the migration cost honestly.
• They keep skipLibCheck: true. It skips type-checking declaration files in node_modules (not your code) — a big speed-up, and it stops one dependency's broken .d.ts from failing your build.
• They put strictness in a shared base via extends so every package is strict by default and no one can quietly opt out.
Emit options — what lands on disk
If tsc (not a bundler) produces your JavaScript, these control the output:
outDir/rootDir— where compiled files go, and the source root that fixes the folder structure insideoutDir.declaration— emit.d.tstype declarations alongside the JS. Essential when you publish a library so consumers get types (that's Chapter 3's subject).sourceMap— emit.mapfiles so debuggers and stack traces point at your.ts, not the compiled.js.noEmit— type-check and produce nothing. Extremely common today: a bundler (Vite, esbuild) does the actual transpilation, andtsc --noEmitruns as a pure type-check in CI.isolatedModules— enforce that each file can be compiled alone, which is exactly what single-file transpilers like esbuild and Babel require. Turn it on if a bundler emits your code.skipLibCheck— as above: skip checking.d.tsfiles from dependencies. Almost universally on.
The Academy is a Next.js + Drizzle/MySQL app, and its tsconfig.json reflects the modern split: "strict": true is non-negotiable, and "noEmit": true is set because Next.js's own toolchain does the compiling — tsc runs only as a type-checker in CI. When a Drizzle query like db.query.users.findFirst() returns User | undefined, strictNullChecks is what forces the code to handle "no such user" before rendering a profile — the difference between a clean 404 and a white-screen crash. Adding noUncheckedIndexedAccess caught a real one: reading lessons[currentIndex] for a lesson that had been unpublished, now correctly typed as possibly undefined.
A sensible starter config
A realistic, modern tsconfig.json for a Node/TypeScript service — strict, honest, and no more than it needs:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}The recommendation is simple: on every new project, start with strict: true and add noUncheckedIndexedAccess. If you'd rather not curate the list yourself, the community package @tsconfig/strictest (and framework presets like @tsconfig/next or the ones shipped by Vite templates) give you a battle-tested base to extends — turn everything on, then relax individual flags only when a real, understood need appears.
Common Mistakes
- Shipping without
strict. The defaulttsc --initin older versions left it off, and a project without it gets a fraction of TypeScript's value while paying its full syntax cost. Check for the line first thing. - Turning
strictoff to silence errors. The errors are the product. Disabling the check doesn't fix the bug — it hides it until a user finds it. - Confusing
targetwithlib.targetsets emitted syntax;libsets available types. WantingArray.atin your types is alibchange; caring what old browser runs the output is atargetchange. - Forgetting
include, sotsctype-checksnode_modulesor build output. Slow builds and bizarre errors from files you don't own. Scope it tosrc. - Assuming
strictgives younoUncheckedIndexedAccess. It doesn't —arr[0]is still typed asT, notT | undefined, until you add that separate flag. - Confusing
skipLibCheckwith skipping your checks. It only skips.d.tsfiles in dependencies; every line of your own code is still fully checked.
Your team inherits a 200,000-line JavaScript codebase and flips strict: true in one commit. The build reports 4,000 errors. Is that a disaster — or a 4,000-item, pre-prioritised map of every place the old code lied about what could be null? Now: would you rather find those 4,000 gaps in an afternoon of red squiggles, or one pager at a time over the next two years? The number didn't create the risk; it revealed risk that was already shipped. Strictness doesn't add danger — it makes existing danger visible while you can still afford to look.
Interview Corner
strict: true do in a tsconfig.json?"strictNullChecks, which stops null and undefined from being silently assignable to any type, plus noImplicitAny, which errors when the compiler can't infer a type instead of falling back to any. You want it on for basically every project."strict does not turn on." → noUncheckedIndexedAccess — indexing still returns T, not T | undefined, unless you add it separately.target, lib, and module, and when would you set noEmit?"target is the JS syntax version tsc emits; lib is the ambient type declarations available, with no runtime effect; module is how import/export are emitted and resolved. I set noEmit when a bundler like Vite or esbuild does the actual transpilation and tsc's only job is type-checking — typically as a CI gate. In that setup I'd also enable isolatedModules, since single-file transpilers can't see across files."skipLibCheck on?" → It skips checking dependency .d.ts files — a real speed-up, and it stops one library's broken declarations from failing your build. Your own code is still fully checked.strict across the whole repo — that's a wall of errors nobody can land. I put a strict base in a shared tsconfig.base.json that all packages extends, so new code is strict by default. For legacy code I'd migrate incrementally — enable one member like strictNullChecks at a time, or scope strictness per-directory with project references, driving the error count down as a tracked metric. noUncheckedIndexedAccess I'd turn on for new packages but weigh carefully on old ones, because it can produce thousands of errors. The goal is a ratchet that only ever tightens — strictness that can't be quietly rolled back."target and lib, what strictFunctionTypes does to variance, why isolatedModules matters. Be ready to be pressed until you hit the real reason.strict umbrella exists (so new checks adopt en masse) and roughly when strictNullChecks landed and what problem it solved.noEmit as a type-check gate and extends for a consistent baseline across many services.- In the editor above, uncomment the
console.log(cheapest.name…)line and read the exact diagnostic. Then narrow the filter top.price < 5(sofindmisses) and confirm theelsebranch is what actually runs — proving why the compiler insisted on the guard. - Run
npx tsc --initin a scratch folder and open the generated file. Findstrict, then addnoUncheckedIndexedAccessyourself. Write a two-line function that returnsarr[0]and watch its inferred return type gain| undefined. - Out loud (as in a micro1 screen), explain in one breath the difference between
targetandlib, using a concrete example likeArray.prototype.at. - Set up an
extends: create atsconfig.base.jsonwithstrict: trueand a childtsconfig.jsonthat extends it and only setsoutDir. Confirm the child still type-checks strictly.
Chapter Summary
tsconfig.jsonis the project's control panel:compilerOptions,include/exclude/files, andextendsfor shared bases. Scaffold it withtsc --init.targetsets the emitted JS syntax;libsets the ambient types available;module/moduleResolutiongovern how imports are emitted and found.strict: trueis the master switch — turn it on for every new project. It bundlesstrictNullChecks(the big one),noImplicitAny,strictFunctionTypes,useUnknownInCatchVariables, and more, and it keeps tightening as you upgrade.- Add
noUncheckedIndexedAccess—strictalone leavesarr[i]typedT, hiding a whole class of missing-value bugs. - Emit options —
outDir,declaration,sourceMap,noEmit,isolatedModules,skipLibCheck— matter whentsc(or a bundler) produces your JavaScript;noEmittype-check-only is the modern default. - Strictness reveals risk rather than creating it; roll it out across large teams with a shared
extendsbase and an ever-tightening ratchet, never a big-bang flip.
We've told the compiler how strict to be and which files it owns — but "which files" quickly becomes "how do files find each other?" The moment a project outgrows one folder, you hit imports, exports, the CommonJS-versus-ESM divide, and the ../../../ path spaghetti that plagues every growing codebase. Next chapter — Modules, Namespaces & Path Aliases — we make module and moduleResolution earn their keep, and turn those brittle relative paths into clean, aliased imports.
Editorial reflection — it is tempting to treat
tsconfig.jsonas boilerplate — something you copy once and never read. Resist that. This file is where you decide, on behalf of every future teammate and every future 3am incident, how much the compiler is allowed to help you.strict: trueis not the compiler being difficult; it is the compiler being honest about what your code actually promised. The best engineers I know turn the dial up on day one, precisely because they respect how fallible they'll be on day two hundred.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.