WritingTypeScript tsconfig Best Practices for Production Node.js Apps — Clixo
5 min readtypescript, nodejs, tsconfig, configuration, production

TypeScript tsconfig Best Practices for Production Node.js Apps

The exact tsconfig.json settings that matter for production Node.js TypeScript apps — what to enable, what to skip, and why each flag exists.

Most teams copy a tsconfig.json from a starter template and never revisit it. That works until you hit a subtle runtime bug that TypeScript should have caught — and would have, if the right flag had been set.

The TypeScript config is not boilerplate. It is a calibration of how much the compiler will help you. Here is what a production Node.js tsconfig.json should look like and why each decision matters.

TypeScript tsconfig Best Practices for Node.js: The Full Config

A production-grade configuration for a Node.js 22+ backend:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "skipLibCheck": false,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Walk through the decisions:

Target and Module

"target": "ES2022" tells TypeScript which JavaScript features to emit. Node 22 supports ES2022 natively, so there is no need to downcompile modern syntax. Using a target older than your runtime adds dead code from polyfills.

"module": "NodeNext" with "moduleResolution": "NodeNext" makes TypeScript handle ESM and CJS correctly for Node.js, including requiring file extensions in imports. This is a breaking change from older configs but it is correct for modern Node.

strict: true and What It Actually Enables

strict: true is a shorthand for eight flags:

  • strictNullChecksnull and undefined are not assignable to other types
  • noImplicitAny — TypeScript will not silently infer any
  • strictFunctionTypes — function parameter types are checked contravariantly
  • strictBindCallApplybind, call, and apply are type-checked
  • strictPropertyInitialization — class properties must be initialized in the constructor
  • noImplicitThisthis in functions must have an explicit type
  • alwaysStrict — emits "use strict" in every output file
  • useUnknownInCatchVariables — catch clause variables default to unknown instead of any

If you are on a greenfield project, enable this from day one. If you are migrating, enable it after the bulk of files are .ts.

noUncheckedIndexedAccess: The Flag strict Misses

This one is not in strict: true and it catches a very common class of runtime crash. Without it, arr[0] has type T even though the array could be empty. With it, arr[0] has type T | undefined, forcing you to handle the empty case.

const ids: string[] = getIds();
const first = ids[0];
// Without flag: first is string — crash if array is empty
// With flag: first is string | undefined — compiler forces a check

Enable it. Fix the errors by adding null coalescing (??) or explicit guards. It is one of the highest-ROI flags available.

noImplicitReturns and noFallthroughCasesInSwitch

noImplicitReturns ensures every branch of a function that declares a return type actually returns. This catches the "function falls off the end and returns undefined silently" bug.

noFallthroughCasesInSwitch catches switch cases that fall through to the next case without a break or return. This is almost always a bug.

exactOptionalPropertyTypes

Without this flag, { age?: number } lets you write obj.age = undefined even though that is semantically different from omitting the property. With it enabled, setting an optional property to undefined is a type error — you must omit the key instead. This matters most when you are serializing to JSON or passing objects to strict APIs.

skipLibCheck

The safe default is false. With skipLibCheck: true, TypeScript skips type-checking .d.ts files in node_modules. This speeds up compilation but lets errors in third-party types silently pass. Only flip this to true if a dependency ships broken types and you have no other option. Document why.

declaration and sourceMap

declaration: true emits .d.ts files alongside your output. This is required if you are publishing a package that other TypeScript projects consume. declarationMap: true adds source maps for those declaration files so IDE "go to definition" navigates to your .ts source rather than the compiled .d.ts.

sourceMap: true maps runtime error stack traces back to your source files. Without it, a production crash points to minified compiled JavaScript, not the line you actually wrote.

Running Type Checks in CI

Your build step should not be your type check. Keep them separate:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc"
  }
}

Run typecheck on every pull request. Run build to produce artifacts. --noEmit is faster because it skips writing files. If typecheck is slow on a large codebase, look at project references to enable incremental checking across packages.


Getting the configuration right is the foundation. If you are building a TypeScript backend and want it done correctly from the start, talk to Clixo.