Free Handbook · Runs in your browser

Configuration

tsconfig.json beyond the five basics, the full strict flag family, why ESLint still matters with strict on, Prettier, and two realistic starter configs for a Node API and a React frontend.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 11 · what you'll be able to do

  • Read tsconfig options beyond the five basics — lib, outDir/rootDir, declaration, sourceMap, the noUnused/noImplicitReturns family, project references
  • List every flag strict turns on and what each one is for
  • Explain why a linter is still necessary in a project with strict: true
  • Set up ESLint and Prettier so they cooperate instead of fighting
  • Write a realistic starter tsconfig for a Node API and for a React frontend
01

tsconfig.json beyond the basics

Module 00 covered the five options every project sets on day one — target, module, strict, esModuleInterop, skipLibCheck. Real projects grow a few more, for output layout, generated declarations, and keeping the codebase from quietly accumulating dead code.

OptionWhat it does
libWhich built-in type definitions are available — ["ES2020", "DOM"] adds browser globals like window; a Node-only project omits DOM so using it is a compile error, not a runtime surprise.
outDir / rootDirWhere compiled .js goes, and which folder is treated as the source root — rootDir: "src", outDir: "dist" mirrors your source tree under dist/ instead of scattering .js next to every .ts.
declarationEmits a matching .d.ts next to every compiled .js — required for anything you publish as a package (Module 09).
declarationMapA source map for the .d.ts itself — lets an editor "go to definition" straight into your original .ts, not the generated declaration.
sourceMapEmits .js.map files so a debugger shows your original TypeScript lines and variable names, not the compiled JavaScript.
noUnusedLocals / noUnusedParametersErrors on a declared-but-never-read local variable, or an unused function parameter — dead code the checker can catch that has nothing to do with types.
noImplicitReturnsErrors if a function has a branch that falls through without an explicit return while other branches do return — a frequent bug in a function with several early-return conditions.
composite / referencesMarks a package as buildable in isolation and lists which other packages it depends on — the basis for project references, below.
jsontsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,

    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true
  },
  "include": ["src"]
}
Project references, for a monorepo
A large codebase split into packages (a shared library, an api, a web app) can wire each package's tsconfig with "composite": true and list its dependencies under "references". A root tsconfig then references all of them, and tsc -b ("build mode") compiles only the packages that changed, in dependency order — instead of one giant program checking everything, every time.
jsonpackages/api/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "references": [
    { "path": "../shared" }
  ]
}
Mid-levelWhat problem do TypeScript project references solve, and when would you reach for them?

In a monorepo with several TypeScript packages that depend on each other, a single flat tsconfig re-checks the entire dependency graph on every build, even for a change in one leaf package. Project references (composite: true plus a references list, built with tsc -b) let the compiler build each package in isolation, in dependency order, and skip packages whose inputs have not changed — turning a full rebuild into an incremental one. It is worth the setup cost once a repo has more than a couple of internal packages that import each other; for a single-package app it is unnecessary complexity.

02

The full strict flag family

"strict": true is shorthand — it turns on every flag in the table below at once. You can also enable or disable any of them individually, which matters most when migrating an existing JavaScript-turned-TypeScript codebase gradually instead of all at once.

FlagWhat it catches
strictNullChecksnull and undefined are no longer assignable to every type — the single highest-impact flag in the family (Module 10, errors 5–6).
noImplicitAnyAn unannotated value the checker cannot infer becomes an error instead of a silent, unchecked any (Module 10, error 6).
strictFunctionTypesFunction parameter types are checked contravariantly on assignment — stops a handler expecting a narrower event type from being assigned where a wider one is required.
strictBindCallApply.bind(), .call() and .apply() are checked against the function's real parameter types, instead of accepting any arguments.
strictPropertyInitializationA class property without a definite value is an error unless it is optional, given a default, or assigned in the constructor — catches a field that is undefined until some later method runs.
noImplicitThisAn error where this would otherwise implicitly be typed any — a plain function used as a callback where this's type is not knowable.
alwaysStrictEmits "use strict" and parses every file in strict-mode JavaScript — the language-level strict mode, unrelated to types.
useUnknownInCatchVariablesA caught error is typed unknown instead of any, forcing a narrowing check before you touch it (Module 10, error 15).
Not everything strict-adjacent is in strict
noUncheckedIndexedAccess (Module 10) and exactOptionalPropertyTypes are deliberately not included in strict — they are newer, stricter than the family above, and would each flag a large amount of code in an existing project that already passes strict. Turn them on individually, and expect a real cleanup pass the first time.
  • New project — start with strict: true, full stop. There is no migration cost yet, so there is no reason to opt out of any of it.
  • Existing JS codebase going strict gradually — turn flags on one at a time, fix what breaks, commit, repeat; strictNullChecks and noImplicitAny are almost always the two biggest and most worthwhile.
  • A single flag failing a huge legacy file — a per-file // @ts-nocheck is an acceptable, visible escape hatch while migrating; a project-wide strict: false forever is not.
JuniorWhat does "strict": true actually do in tsconfig.json?

It is shorthand that turns on a family of roughly eight individual flags together — strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict and useUnknownInCatchVariables. Each can also be set individually; strict: true is simply the recommended default for a new project, since together they close most of the gaps that let a bug through the type checker.

03

ESLint and @typescript-eslint

strict: true makes the type checker sound; it says nothing about style, patterns, or the many ways to write correctly-typed code badly. @typescript-eslint is a parser and rule set that lets ESLint understand TypeScript syntax and — for its more powerful rules — actually query the type checker itself, catching a different class of problem entirely.

Kind of problemCaught by strict?Caught by ESLint?
string assigned where number expectedYes
A floating promise — a call to an async function whose result is silently droppedNo (it type-checks fine)Yes — no-floating-promises
any used to silence a real type errorNoYes — no-explicit-any
Inconsistent import style, unsorted imports, naming conventionNoYes
An == where === was meantNoYes — eqeqeq
javascripteslint.config.js
import tseslint from "typescript-eslint"

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        project: true,               // enables TYPE-AWARE rules — needs your real tsconfig
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": "error",
    },
  },
)
Type-aware rules cost real time
no-floating-promises and similar rules need the type checker running behind ESLint, via parserOptions.project pointing at a real tsconfig. This makes linting noticeably slower than plain syntax rules — worth it for what it catches, but a reason CI often lints in parallel with, rather than blocking, other checks.
Mid-levelThe project already has strict: true. Why add a separate linter on top of that?

The type checker and a linter answer different questions. strict makes the types sound — it refuses code that could produce a type-level bug — but it has no opinion on patterns that are perfectly well-typed and still wrong: a promise whose rejection is never handled, an any smuggled in to dodge a real error, inconsistent style across a team. @typescript-eslint adds rules, including type-aware ones, that check for exactly that class of problem, which strict was never designed to catch and never will.

04

Prettier

Prettier only formats — indentation, quote style, line length, trailing commas — and has no opinion about correctness. Its entire value is ending the argument: everyone's code comes out identical regardless of how it was typed, and code review stops discussing tabs versus spaces.

json.prettierrc
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "all"
}

ESLint has some formatting-flavoured rules of its own (indentation, quote style) that can disagree with Prettier and fight over the same line. The fix is not to run both formatters — it is to turn ESLint's formatting rules off entirely and let Prettier own formatting alone.

javascripteslint.config.js
import eslintConfigPrettier from "eslint-config-prettier"

export default [
  // ...your other config...
  eslintConfigPrettier,   // MUST be last — disables every ESLint formatting rule
]
Two tools, two separate steps
Run them as separate npm scripts (lint, format) rather than chaining Prettier through ESLint as a rule (eslint-plugin-prettier) — running Prettier directly is faster, and a formatting difference shows up as a plain diff instead of a lint error.
jsonpackage.json (scripts)
{
  "scripts": {
    "lint": "eslint .",
    "format": "prettier --write .",
    "format:check": "prettier --check ."
  }
}
JuniorWhy do teams run both ESLint and Prettier instead of just one of them?

They solve different problems: Prettier formats code consistently with no opinion on whether it is correct, while ESLint (with @typescript-eslint) checks for bugs and patterns — a missing await, a lingering any, inconsistent imports — with no opinion on formatting. Configuring ESLint's own formatting rules off via eslint-config-prettier avoids them disagreeing with each other over the same line.

05

Two realistic starter configs

The same five basics, tuned differently for what actually runs the output — a Node API compiles with tsc itself and runs on a server with no browser in sight; a React frontend hands its TypeScript to a bundler that does its own transform and never asks tsc to emit anything at all.

jsontsconfig.json — Node API
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,

    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "sourceMap": true,

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true
  },
  "include": ["src"]
}
jsontsconfig.json — React frontend
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,

    "noEmit": true
  },
  "include": ["src"]
}

Node API

  • module / moduleResolution: NodeNext — matches real Node ESM resolution, including required .js extensions on relative imports
  • lib: ["ES2022"] — no DOM; using window by accident is a compile error, not a deploy-time surprise
  • declaration: true and an outDirtsc is the build; it actually emits the JavaScript that ships
  • No jsx setting — nothing here renders UI

React frontend

  • moduleResolution: bundler — matches what Vite/webpack actually do, not raw Node
  • lib includes DOMwindow, document, event types are all needed and expected
  • jsx: "react-jsx" — lets .tsx files use JSX without importing React just for that
  • noEmit: true — the bundler compiles and emits the JavaScript; tsc here only type-checks
Mid-levelWhy does a frontend tsconfig usually set noEmit: true when a Node API tsconfig does not?

In a bundler-driven frontend project, Vite, webpack or esbuild already strip types and produce the shipped JavaScript themselves, usually faster than tsc and integrated with hot reload — so tsc runs only as a type checker, not a compiler, and noEmit stops it from also writing out .js files nobody uses. A Node API with no bundler in front of it has nothing else to produce the JavaScript that actually runs, so tsc is the build step and needs to emit — noEmit would leave it with nothing to deploy.

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.