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:

  1. What JavaScript features should TypeScript understand?
  2. How should TypeScript resolve imports?
  3. 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:

  1. Does package.json use "type": "module"?
  2. Does module match the runtime?
  3. Does moduleResolution match the build tool?
  4. Is noEmit expected?
  5. Is the file inside include?
  6. 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.

Official references