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
targetat thetsc --initdefault ofES2016. 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.
| Setting | Use when… |
|---|---|
NodeNext | Targeting modern Node and honouring package.json type/exports. |
CommonJS | Shipping a legacy CJS-only package that uses require. |
ESNext | A 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
NodeNextwith ESM, relative imports must carry the.jsextension —import { db } from "./db.js"— even though the source file isdb.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.
A recommended Node.js configuration
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
targetto match your Node version (ES2022for Node 20,ES2023for Node 22) so the compiler never down-levels syntax Node already supports. - Use
NodeNextfor bothmoduleandmoduleResolutionso TypeScript follows Node’s real resolution rules, includingpackage.jsonexports. - Always enable
strict: trueon new projects — it catches null bugs and implicitanybefore they reach production. - Set
rootDirandoutDirexplicitly, keep them separate, and adddist/to.gitignore. - Turn on
esModuleInteropandskipLibCheckfor smoother third-party imports and faster builds. - Add a
tsc --noEmittypecheck step to CI so type errors fail the build independently of your bundler.