tsconfig.json Configuration
tsconfig.json tells the TypeScript compiler which files belong to the project, how strictly to check them, what JavaScript and module format to produce (if any), and how to resolve imports. Most "TypeScript is weird" moments trace back to it: imports that work in the editor but fail at runtime, strict switched off years ago, or path aliases the bundler understands and Node doesn't.
The option list is long, but only a dozen or so settings matter for most projects. This page explains those and gives sensible baselines for the common setups.
TL;DR
- Turn on
"strict": trueand never turn it off. AddnoUncheckedIndexedAccessfor extra safety. targetsets the JavaScript syntax level emitted;libsets which built-in APIs are typed (DOM, ES2023…).module/moduleResolution: use"bundler"for Vite, Next.js, and other bundled apps, and"nodenext"for code Node runs directly.- With a bundler, set
"noEmit": true. TypeScript only type-checks; the bundler strips types. isolatedModules/verbatimModuleSyntaxkeep your code compatible with single-file transpilers (esbuild, SWC, Babel).pathsonly affects type-checking. Your bundler or runtime must be configured to resolve the same aliases.
Quick Example
A strict baseline for a bundled web app (Vite or Next.js style):
tsc --noEmit (often npm run typecheck) then checks the whole project in CI, while Vite transpiles each file independently during development.
Core Concepts
Strictness
"strict": true enables a family of checks, including:
strictNullChecks:nullandundefinedaren't assignable to other types. This is the single most valuable flag.noImplicitAny: parameters without inferable types must be annotated.strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,useUnknownInCatchVariables, and more.
Worth adding on top:
Target and Lib
target controls syntax downleveling: with ES2017, optional chaining gets compiled to older code. lib controls which APIs TypeScript believes exist: including "DOM" types document and window, and "ES2023" types Array.prototype.findLast. lib doesn't polyfill anything; it only declares types. For Node projects, omit "DOM" and install @types/node.
Module and Module Resolution
These decide how import statements are interpreted and resolved:
Picking the wrong mode is the root of "works in the editor, breaks at runtime" import bugs. Match the mode to whatever actually executes or bundles your code.
Emit or No Emit
- Bundled apps:
noEmit: true. The bundler strips types;tscis purely a type checker. - Libraries and Node services compiled by
tsc: setoutDir,rootDir,declaration: true(to emit.d.ts), andsourceMap: true. - Node 22.6+ type stripping and runtimes like Bun and Deno run
.tsdirectly. EnableerasableSyntaxOnlyto avoid features (enums, namespaces, parameter properties) that plain type stripping can't handle.
isolatedModules and verbatimModuleSyntax
Single-file transpilers (esbuild, SWC, Babel, Node's type stripping) compile each file without seeing the others, so they can't tell whether an import is a type or a value. isolatedModules flags code that depends on cross-file knowledge. verbatimModuleSyntax goes further: imports are kept or dropped exactly as written, so type-only imports must use import type.
Project Structure
include, exclude, files
include lists globs of source files; exclude removes some (it defaults to node_modules and outDir). Test files often get a separate config that extends the main one and adds test globals.
extends
Share settings through inheritance: "extends": "./tsconfig.base.json" or community presets like @tsconfig/strictest and @tsconfig/node22. Child configs override individual options.
Project References
In monorepos, references plus "composite": true split the codebase into sub-projects with explicit dependencies. tsc --build then compiles incrementally and only re-checks what changed. It's faster for large repos, at the cost of more configuration.
Best Practices
Start Strict, Stay Strict
Enabling strict on a new project costs nothing; retrofitting it later costs weeks. For an existing loose project, turn on flags one at a time and fix errors incrementally, beginning with strictNullChecks.
Type-Check in CI Separately From Building
Fast transpilers skip type-checking entirely. Run tsc --noEmit (or tsc -b) as its own CI step so type errors fail the build.
Keep Aliases in One Place
If you use paths, make sure the bundler (Vite's resolve.alias, or vite-tsconfig-paths), test runner, and runtime resolve the same aliases. Node's package.json "imports" field (#src/*) is a standards-based alternative that both TypeScript and Node understand.
Use skipLibCheck
It skips type-checking third-party .d.ts files. That's much faster, and it avoids errors in dependencies you can't fix. Your own code is still fully checked.
Common Mistakes
paths Without Runtime Support
Configure the bundler or runtime to resolve the alias, or use Node subpath imports.
Mixing ESM Output With CommonJS Package Settings
With "module": "nodenext", whether a file is ESM or CJS depends on package.json "type" and the file extension (.mts/.cts). Mismatches produce require is not defined or ERR_REQUIRE_ESM at runtime. Decide on ESM and set "type": "module".
Disabling Checks to Silence Errors
Setting strict: false or noImplicitAny: false to make an upgrade compile trades a handful of visible errors for hundreds of invisible bugs. Fix or locally suppress with // @ts-expect-error and a comment instead.
FAQ
Should I use moduleResolution: "bundler" or "nodenext"?
Use bundler when a bundler or a runtime like Bun processes your imports, which covers most frontend and full-stack apps. Use nodenext when Node itself loads your compiled output, as with libraries published to npm and Node services compiled with tsc. Libraries should use nodenext so they work for every consumer.
Why does TypeScript want .js extensions in my imports?
Under nodenext with ESM, Node requires full file paths, and TypeScript doesn't rewrite import specifiers. You write the path of the output file (./util.js) even though the source is util.ts. With rewriteRelativeImportExtensions (TS 5.7+) you can write .ts and have it rewritten on emit.
What does skipLibCheck hide?
Type errors inside .d.ts files, mostly from node_modules. It doesn't affect checking of your own .ts files. Nearly every project enables it for speed and to avoid conflicts between dependency type definitions.
Do I need a separate tsconfig for tests?
Often, yes. A tsconfig.test.json that extends the base and adds test-runner types ("types": ["vitest/globals"]) keeps test globals out of your app code. Many frameworks generate separate app, node, and test configs for exactly this reason.
Related Topics
- TypeScript — The language overview
- TypeScript Declaration Files —
.d.tsoutput andtypes - Vite — A bundler that pairs with
moduleResolution: bundler - Monorepos — Project references at scale
- Build Tools — Transpilers and bundlers around
tsc