Skip to content
Node.js nd typescript 4 min read

tsconfig.json for Node.js Explained

The tsconfig.json file is the control panel for the TypeScript compiler: it tells tsc which files to compile, what JavaScript to emit, how to resolve imports, and how strict to be about types. For Node.js projects a handful of options do most of the heavy lifting, and getting them right means your compiled code runs cleanly on the runtime you target while catching bugs at build time. This page walks through the options that matter most for modern Node 20/22 LTS — target, module/moduleResolution, outDir/rootDir, strict, and esModuleInterop — and ends with a recommended configuration you can copy.

How tsconfig.json is structured

A tsconfig.json lives at the project root. Settings that affect compilation live under compilerOptions, while include and exclude (or files) control which source files the compiler picks up. When you run tsc with no file arguments, it discovers the nearest tsconfig.json and obeys it.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

The include glob keeps the compiler focused on your src/ tree, and exclude prevents it from wandering into dependencies or its own output. You can confirm what the effective configuration resolves to with --showConfig:

npx tsc --showConfig

target: the JavaScript syntax level

target controls how modern the emitted JavaScript is allowed to be. If you set a low target like ES2015, the compiler down-levels newer syntax (optional chaining, top-level await, class fields) into older equivalents. Because Node 20 and 22 support everything through ES2022 natively, there is no reason to down-level — set target to ES2022 (or ES2023 on Node 22) and let the runtime execute modern syntax directly. This produces smaller, faster, more readable output.

{ "compilerOptions": { "target": "ES2022" } }

A common mistake is leaving target at the tsc --init default of ES2016. That forces TypeScript to polyfill features Node already has, bloating your output for no benefit. Always raise it to match your Node version.

module and moduleResolution

These two options decide how import/export and require are emitted and how the compiler finds the files those statements point to. For Node, the modern answer for both is NodeNext, which makes TypeScript follow Node’s own module algorithm — including the "type" field and "exports" map in package.json.

SettingUse when…
NodeNextTargeting modern Node and honouring package.json type/exports.
CommonJSShipping a legacy CJS-only package that uses require.
ESNextA bundler (Vite, esbuild) handles module emission, not tsc.

With NodeNext, whether a file becomes ESM or CommonJS is driven by package.json. Set "type": "module" for ESM:

{ "name": "app", "type": "module" }

Under NodeNext with ESM, relative imports must carry the .js extension — import { db } from "./db.js" — even though the source file is db.ts. The extension refers to the compiled output Node will load at runtime.

outDir and rootDir

rootDir tells the compiler where your source tree begins, and outDir says where to place the emitted JavaScript. Keeping them distinct (./src and ./dist) means your generated .js files never mix with your .ts source, your imports stay clean, and you can simply .gitignore the dist/ folder.

{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

If you omit rootDir, TypeScript infers it from the longest common path of your input files, which can shift unexpectedly when you add a file outside src/ — set it explicitly to keep the output layout stable.

strict and the safety options

strict is the single highest-value option. Setting it to true enables a family of checks at once: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, and more. The most impactful is strictNullChecks, which forces you to handle undefined/null rather than letting them slip through.

// src/config.ts
function port(value: string | undefined): number {
  // With strictNullChecks, this branch is required.
  if (value === undefined) return 3000;
  return Number.parseInt(value, 10);
}

console.log(port(process.env.PORT));

Output:

3000

Pair strict with esModuleInterop: true, which lets you write import express from "express" for CommonJS packages that use module.exports, instead of the awkward import * as express. Keep skipLibCheck: true to skip type-checking inside node_modules, which speeds up builds and avoids conflicts between third-party .d.ts versions.

Putting it together, here is a lean, modern starting point for a Node 22 service or library:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "sourceMap": true,
    "declaration": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

sourceMap maps runtime stack traces back to your TypeScript lines while debugging, and declaration emits .d.ts files so consumers of a published package get types. Drop declaration if you are building an application rather than a library.

Best Practices

  • Set target to match your Node version (ES2022 for Node 20, ES2023 for Node 22) so the compiler never down-levels syntax Node already supports.
  • Use NodeNext for both module and moduleResolution so TypeScript follows Node’s real resolution rules, including package.json exports.
  • Always enable strict: true on new projects — it catches null bugs and implicit any before they reach production.
  • Set rootDir and outDir explicitly, keep them separate, and add dist/ to .gitignore.
  • Turn on esModuleInterop and skipLibCheck for smoother third-party imports and faster builds.
  • Add a tsc --noEmit typecheck step to CI so type errors fail the build independently of your bundler.
Last updated June 14, 2026
Was this helpful?