TypeScript in the Real World

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.

The question this chapter answers

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 save

The 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, tsc grabs every .ts under the config's folder, which is rarely what you want.
  • exclude — patterns carved back out of include (it does not apply to files). Defaults to node_modules, bower_components, and your outDir — you'll add things like dist and test fixtures.
  • files — an explicit, unglobbed list of exact paths. Handy for tiny projects; almost everyone reaches for include instead.

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 version tsc emits. "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 you document and fetch; "ES2022" gives you Array.prototype.at and Object.hasOwn. A Node backend drops "DOM"; a browser app keeps it. If you omit lib, it's inferred from target.

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.

Everyday analogy

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:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

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.

History changed here

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 makes arr[i] and record[key] return T | undefined, because indexing can always miss. This is honest in a way plain strict isn't: strict happily lets const x = arr[0] be typed T even 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 say override. Rename the parent method and the compiler flags the now-orphaned child instead of silently keeping both.
  • noFallthroughCasesInSwitch — a case that does work and forgets break becomes 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 to undefined." Precise, occasionally pedantic.
  • verbatimModuleSyntax — keeps your import/export statements exactly as written (dropping only import type), removing guesswork about what's elided. More in the next chapter.
The engineer's notebook

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 inside outDir.
  • declaration — emit .d.ts type declarations alongside the JS. Essential when you publish a library so consumers get types (that's Chapter 3's subject).
  • sourceMap — emit .map files 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, and tsc --noEmit runs 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.ts files from dependencies. Almost universally on.
Real-world example — chika.ng Academy

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 default tsc --init in 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 strict off 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 target with lib. target sets emitted syntax; lib sets available types. Wanting Array.at in your types is a lib change; caring what old browser runs the output is a target change.
  • Forgetting include, so tsc type-checks node_modules or build output. Slow builds and bizarre errors from files you don't own. Scope it to src.
  • Assuming strict gives you noUncheckedIndexedAccess. It doesn't — arr[0] is still typed as T, not T | undefined, until you add that separate flag.
  • Confusing skipLibCheck with skipping your checks. It only skips .d.ts files in dependencies; every line of your own code is still fully checked.
Thought experiment

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

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"What does strict: true do in a tsconfig.json?"
Model answer — "It's a master switch that turns on a whole family of stricter checks at once — the most important being 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."
Follow-up they'll throw: "Name one thing strict does not turn on." → noUncheckedIndexedAccess — indexing still returns T, not T | undefined, unless you add it separately.
Intermediate
"What's the difference between target, lib, and module, and when would you set noEmit?"
Model answer — "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."
Follow-up: "Why keep 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.
Senior
"You're adopting TypeScript across a large existing JS codebase and a team of twenty. How do you roll out strictness without stalling everyone?"
Model answer — "I'd never big-bang 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."
The senior signal is treating config as a migration and governance problem — shared base, ratchet, tracked metrics — not a single boolean you flip and pray.
Company lens — how the same topic gets asked
micro1Live and conversational — reason out loud. Expect "walk me through the tsconfig you'd start a new service with, and why each line's there." Narrate the trade-offs as you go; a confident, ordered answer beats a memorised list.
GoogleWants precision on the mechanism: the exact difference between target and lib, what strictFunctionTypes does to variance, why isolatedModules matters. Be ready to be pressed until you hit the real reason.
MicrosoftThey authored TypeScript — expect depth and history. Know why the strict umbrella exists (so new checks adopt en masse) and roughly when strictNullChecks landed and what problem it solved.
Amazon / AWSOperational lens: how strictness plays in CI, build times, and large-team governance. Tie it to noEmit as a type-check gate and extends for a consistent baseline across many services.
Hands-on exercise
  1. In the editor above, uncomment the console.log(cheapest.name…) line and read the exact diagnostic. Then narrow the filter to p.price < 5 (so find misses) and confirm the else branch is what actually runs — proving why the compiler insisted on the guard.
  2. Run npx tsc --init in a scratch folder and open the generated file. Find strict, then add noUncheckedIndexedAccess yourself. Write a two-line function that returns arr[0] and watch its inferred return type gain | undefined.
  3. Out loud (as in a micro1 screen), explain in one breath the difference between target and lib, using a concrete example like Array.prototype.at.
  4. Set up an extends: create a tsconfig.base.json with strict: true and a child tsconfig.json that extends it and only sets outDir. Confirm the child still type-checks strictly.

Chapter Summary

  • tsconfig.json is the project's control panel: compilerOptions, include/exclude/files, and extends for shared bases. Scaffold it with tsc --init.
  • target sets the emitted JS syntax; lib sets the ambient types available; module/moduleResolution govern how imports are emitted and found.
  • strict: true is the master switch — turn it on for every new project. It bundles strictNullChecks (the big one), noImplicitAny, strictFunctionTypes, useUnknownInCatchVariables, and more, and it keeps tightening as you upgrade.
  • Add noUncheckedIndexedAccess — strict alone leaves arr[i] typed T, hiding a whole class of missing-value bugs.
  • Emit options — outDir, declaration, sourceMap, noEmit, isolatedModules, skipLibCheck — matter when tsc (or a bundler) produces your JavaScript; noEmit type-check-only is the modern default.
  • Strictness reveals risk rather than creating it; roll it out across large teams with a shared extends base and an ever-tightening ratchet, never a big-bang flip.
Narrative bridge

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.json as 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: true is 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.