TypeScript config and tooling · 2 / 8
lesson 2

The tsconfig.json tour

A walkthrough of the flags you actually use, in the order you meet them.

~ 17 min read·lesson 2 of 8
0 / 8

You opened a tsconfig.json from a starter template. There are forty fields. Half of them are commented out. None of them are explained. You're not sure which ones to touch and which ones to leave alone, so you leave it all alone — and then a teammate flips one, and the whole project starts behaving differently.

This lesson is the tour. Nine flags, the ones you actually meet in real projects, in the order you tend to learn them. Skip the rest until something forces you to care.

The shape of the file

tsconfig.json is JSON with comments allowed. The two top-level fields you'll touch are compilerOptions (everything that controls the compiler) and include / exclude (which files belong to the project).

tsconfig.json
{
"compilerOptions": {
  "target": "ES2022",
  "module": "ESNext",
  "moduleResolution": "bundler",
  "strict": true,
  "lib": ["ES2022", "DOM"],
  "jsx": "react-jsx",
  "isolatedModules": true,
  "skipLibCheck": true,
  "noEmit": true
},
"include": ["src"]
}

Read it top to bottom. Every flag answers a different question — what JS to emit, where to find imports, which globals to trust, how to handle JSX. The rest of this lesson walks each one.

Tip

When you're not sure what a flag does, hover it in your editor. The TypeScript language server shows the official description inline. That hover docs is canonical.

target — what JS to emit

target tells the compiler which version of JavaScript to output. ES2022, ES2017, ES5 — these are JavaScript editions. Higher numbers mean newer features available in the output without polyfills.

If you set target: "ES5" and use async/await, the compiler rewrites them as a state machine using the Promise available in ES5 land. If you set target: "ES2022", the same async keyword stays as async in the output because modern engines support it natively.

src/timer.ts
async function wait(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}

With target: "ES2022" the output is essentially the same code minus the type annotations. With target: "ES5" the output is twenty lines of generated state-machine code that does the same thing. Both work. The newer target produces smaller, faster output because modern browsers can run modern syntax directly.

For a web app that supports modern browsers only, ES2022 is a safe default. For a library that wants to support older runtimes, lower the target.

check your understanding
You set target: "ES2022" but your bundler also has its own browserslist set to "last 2 versions, IE 11". What ships to IE 11?

module and moduleResolution

These two are easy to confuse. They answer different questions.

module is what the compiler outputs. ESNext and ES2022 mean "emit import and export keywords as-is". CommonJS means "rewrite imports as require() calls and exports as module.exports". For a project the bundler will process, leave the import keywords intact — let the bundler decide what runtime form they take.

moduleResolution is how the compiler finds a file when you write import { x } from './foo'. It is not about output format. It is about lookup rules.

The values you'll meet:

  • bundler — the modern default for app projects bundled with Vite, Webpack, esbuild. Skips file-extension requirements and follows the rules bundlers actually use.
  • node — the older Node-style lookup. Adds .js, .ts, index.js automatically. Fine for most non-bundler projects.
  • nodenext — the strict modern Node mode. Requires explicit .js extensions in imports (yes, even for .ts files). Use when targeting native Node ESM.
src/index.ts
// with moduleResolution: "bundler"
import { Cart } from './cart';

// with moduleResolution: "nodenext"
import { Cart } from './cart.js';   // .js even though the file is cart.ts

The .js in nodenext looks wrong. It is correct. Native Node ESM needs the runtime extension; the compiler maps .js back to .ts at type-check time. For an app behind a bundler, pick bundler and forget the extensions.

Lesson 4 covers this in detail. For now: module: "ESNext" plus moduleResolution: "bundler" is the right pair for most modern app projects.

check your understanding
Your project uses moduleResolution: "node". You added "type": "module" to package.json and now imports without extensions break in Node. What changed?

lib — which globals you trust

lib is the list of built-in type definitions the compiler should include. It is how the compiler knows that document.querySelector exists, or that Array.prototype.flatMap is real.

By default the compiler picks a lib based on target, plus the DOM. You override lib when the default is wrong. The two common cases:

  • A Node-only project does not have document. Set lib: ["ES2022"] (no DOM) so referencing window or document becomes a type error.
  • A modern browser app wants newer features than its target. Set lib: ["ES2022", "DOM", "DOM.Iterable"] to expose iterable forms of DOM collections.
tsconfig.json
{
"compilerOptions": {
  "target": "ES2022",
  "lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}

The mental model: target controls what runs; lib controls what you're allowed to call. They overlap by default. You separate them when one needs to be wider or narrower than the other.

check your understanding
A Node CLI tool is throwing document is not defined at runtime, but the editor never warned about document.title. Which tsconfig change would have caught this?

jsx — for React projects

jsx controls how JSX syntax (<Button />) gets compiled. The values that matter today:

  • react-jsx — the modern transform. Imports jsx/jsxs from react/jsx-runtime automatically. You no longer need import React at the top of every file.
  • preserve — leave JSX alone in the output. Use when a downstream tool (a bundler, Babel) handles JSX transformation.
tsconfig.json
{
"compilerOptions": {
  "jsx": "react-jsx"
}
}

For a Vite or Next.js React project, react-jsx is the answer. For a project where Babel or another bundler handles JSX, preserve keeps tsc out of that lane.

If you set the wrong value, you'll see one of two errors. With jsx missing entirely, the compiler complains that JSX syntax is not allowed. With jsx: "preserve" and no downstream JSX handler, the JSX literals end up in your output as syntax errors at runtime.

isolatedModules — the bundler-safety flag

isolatedModules: true tells tsc: "Pretend each file gets compiled alone, with no knowledge of the others."

That sounds artificial. It is exactly what most bundlers actually do. esbuild and swc compile files in parallel, one at a time, with no cross-file type information. Some TypeScript features quietly require cross-file type info to compile correctly. If you use those features in a project a single-file bundler will process, the output is wrong.

The flag forbids those features at type-check time. You get a clear error in your editor instead of mysterious runtime breakage.

src/types.ts
// with isolatedModules: true, this is an error:
export { type User } from './models';   /* must use 'export type {...}' */

// fix:
export type { User } from './models';

const enum, re-exporting a type without the type keyword, ambient const enum from .d.ts — all banned under isolatedModules. The fixes are mechanical. For any project a non-tsc bundler is going to process, turn this on. Vite ships it on by default.

Watch out

If you skip isolatedModules in a Vite project and rely on a feature it would have flagged, the dev server "works" while the production build silently produces broken JavaScript. The flag is a safety net for the bundler's blind spots.

check your understanding
Your project has isolatedModules: false. You re-export a type with export { type User } from './models'. The dev server runs fine but the production bundle crashes. What's the most likely cause?

skipLibCheck — the speed flag

skipLibCheck: true tells tsc to skip type-checking inside .d.ts files in node_modules.

Type definitions for third-party packages can have their own internal type errors, especially when two packages reference each other's types and one is slightly out of date. Those errors are not yours to fix. Without skipLibCheck, they fail your build. With it on, tsc still uses the type definitions to check your code; it just doesn't audit the definitions themselves.

The cost: a real bug in a published .d.ts will not surface in your build. The benefit: faster type-checks, and you stop fighting other people's type errors. The trade is almost always worth it. Vite, Next.js, and the official starters all set this on.

tsconfig.json
{
"compilerOptions": {
  "skipLibCheck": true
}
}

Putting it together

Here is a tsconfig.json for a typical Vite + React app. Every flag has earned its slot.

tsconfig.json
{
"compilerOptions": {
  "target": "ES2022",
  "module": "ESNext",
  "moduleResolution": "bundler",
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
  "jsx": "react-jsx",
  "strict": true,
  "isolatedModules": true,
  "skipLibCheck": true,
  "noEmit": true,
  "esModuleInterop": true,
  "resolveJsonModule": true
},
"include": ["src"]
}

The two extras: esModuleInterop makes default-importing CommonJS packages (import express from 'express') work the way you expect, and resolveJsonModule lets you import data from './data.json' and get a typed object back. Both are on in essentially every modern config.

noEmit is here because Vite handles the JavaScript output. tsc only type-checks. If you remove noEmit and run tsc, you get a dist/ folder full of plain JavaScript — usually duplicated work, sometimes intentional (libraries do this), almost never what an app project wants.

check your understanding
You add resolveJsonModule: true and import a JSON file. Your editor types it as any. Is that what you wanted?
check your understanding
A teammate removes skipLibCheck and the build now fails with errors inside node_modules/@types/react/index.d.ts. What's the right next step?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read