Your project runs locally. CI fails with a module-resolution error. One teammate changes module, another downgrades a package, and nobody knows whether the bug belongs to TypeScript, Node, the bundler, or package.json. That is the moment tsconfig.json stops looking like harmless setup.
That is why I do not treat tsconfig.json like a file to copy from Stack Overflow. It is the contract between your editor, your build tool, and the runtime that eventually executes the code.
Quick answer
Most TypeScript projects become easier to debug when you understand these options first:
| Option | Plain meaning |
|---|---|
target |
What JavaScript version TypeScript is allowed to output |
module |
What import/export format the output should use |
moduleResolution |
How TypeScript finds files and packages |
strict |
Whether TypeScript should catch unsafe assumptions |
lib |
Which global APIs TypeScript knows exist |
include |
Which files TypeScript checks |
noEmit |
Whether TypeScript checks only or also writes output |
If your app builds in one place and fails in another, check these before changing random package versions.
the config is answering three questions
Most teams only need to understand a small set of options deeply.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
This file answers three questions:
- What JavaScript features should TypeScript understand?
- How should TypeScript resolve imports?
- How strict should the type checker be?
If you know those answers, most tsconfig issues become easier to debug.
target decides the JavaScript level
target controls the JavaScript version TypeScript is allowed to emit.
{
"target": "ES2022"
}
For a modern Node backend, ES2022 is a sane starting point. It lets TypeScript keep modern syntax instead of aggressively rewriting code for old environments.
For browser apps, the answer depends on your support policy. If your bundler handles transpilation, TypeScript may only be checking types while the bundler decides the final browser output.
The mistake is setting target without knowing who runs the final JavaScript.
module decides the output format
module controls whether TypeScript thinks in CommonJS, ES modules, or Node’s newer ESM-aware modes.
These errors often point at a module mismatch:
Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
Those messages are not random. They usually mean your tsconfig, package.json, and runtime disagree.
For a Node ESM project, this pair is usually the one to consider:
{
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
For a Vite, Astro, or Next.js app, you will often see:
{
"module": "ESNext",
"moduleResolution": "Bundler"
}
That works because the bundler owns the final module output.
moduleResolution is where many confusing bugs live
moduleResolution tells TypeScript how to follow imports.
This matters when packages use modern exports fields, when file extensions are required, or when Node and your bundler do not behave the same way.
If you are writing code that runs directly in Node, use Node-shaped settings. If you are writing code that goes through a bundler, use bundler-shaped settings.
The bad version is this:
{
"module": "ESNext",
"moduleResolution": "Node"
}
That combination can be fine in old setups, but in modern ESM projects it often becomes a quiet source of wrong assumptions.
verbatimModuleSyntax makes imports less surprising
Modern projects should also understand verbatimModuleSyntax:
{
"verbatimModuleSyntax": true
}
With this option, TypeScript leaves normal import and export statements in place instead of silently rewriting them into a different module system. Imports used only as types should be explicit:
import type { Request } from "express";
That makes the source code’s module intent easier to see. It also exposes mismatches earlier: if your file is being treated as CommonJS but uses ESM syntax, TypeScript reports the disagreement instead of hiding it through output rewriting.
Do not enable it as a random fix. First decide whether Node or a bundler owns module behavior, then align package.json, file extensions, module, and moduleResolution.
strict is not a personality choice
strict: true is the difference between TypeScript acting like a spelling assistant and TypeScript acting like a safety net.
{
"strict": true
}
It turns on checks that catch common bugs:
- A function parameter silently becoming
any. - A value from
.find()being used as if it always exists. - A class field being declared but never initialized.
- A caught error being treated like it is always an
Error.
If the project is new, turn strict mode on immediately.
If the project is a JavaScript migration, turn it on deliberately. Fix the highest-risk paths first: request handlers, queue workers, payment logic, auth, and database writes.
noEmit keeps TypeScript in its lane
Many modern projects do not use tsc to create production JavaScript. They use Vite, Astro, Next.js, SWC, esbuild, or another build tool.
In those projects, this is normal:
{
"noEmit": true
}
That means TypeScript checks the code but does not write output files. The bundler builds the app.
If you expect tsc to generate a dist folder and nothing appears, check noEmit.
lib controls the globals TypeScript knows
lib decides which built-in APIs are available in the type environment.
Frontend app:
{
"lib": ["ES2022", "DOM"]
}
Backend-only Node app:
{
"lib": ["ES2022"]
}
If your backend project includes DOM, TypeScript may let browser-only assumptions sneak into server code. If your frontend project does not include DOM, TypeScript may complain about document, window, or browser fetch.
skipLibCheck is a tradeoff
Most application projects set:
{
"skipLibCheck": true
}
That skips type checking dependency declaration files. It can make builds faster and avoid failures caused by package type conflicts you do not control.
For app code, I usually accept that tradeoff. For a library, I would be more cautious because your public types are part of the product.
include is the first place to check when files are ignored
If TypeScript is not checking a file, inspect include.
{
"include": ["src"]
}
If generated files are causing errors, inspect exclude.
{
"exclude": ["dist", "node_modules"]
}
The most annoying version of this bug is when your editor and CI are checking different sets of files. Keep the include pattern boring.
the configs I would actually start with
For a modern bundled frontend:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}
For a modern Node backend:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2022"]
},
"include": ["src"]
}
These are not universal. They are starting points. The runtime still gets the final vote.
what I check first when TypeScript breaks
When a TypeScript project starts behaving strangely, I check in this order:
- Does
package.jsonuse"type": "module"? - Does
modulematch the runtime? - Does
moduleResolutionmatch the build tool? - Is
noEmitexpected? - Is the file inside
include? - Is strict mode revealing a real missing case?
Here are common messages and the first assumption to inspect:
| Error or symptom | Check first |
|---|---|
Cannot use import statement outside a module |
package.json type and the runtime module format |
require is not defined in ES module scope |
CommonJS code running as ESM |
| Cannot find a package that is installed | moduleResolution and the package’s exports field |
No dist folder after tsc |
Whether noEmit is enabled |
| Editor passes but CI fails | TypeScript version, Node version, and include patterns |
| Relative imports require file extensions | Node ESM rules and NodeNext resolution |
When a runtime upgrade exposes one of these errors, do not immediately downgrade every dependency. First align the runtime and compiler assumptions. The Node.js 26 versus Node.js 24 guide shows how to test that change across local development, CI, and deployment.
Most tsconfig debugging is not about memorizing every option. It is about finding which part of the toolchain is making a different assumption.
That is the rule I keep coming back to: match the runtime, keep the config boring, and let strict mode make the risky assumptions visible.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.