<< Home

PART 3: TypeScript in Node

Chapter 21: Project Setup for TS in Node

TypeScript and Node work well together once the project chooses a runtime module system, a matching compiler configuration, and a layout that keeps build artifacts separate from sources. This chapter gives you a practical baseline that you can apply to new apps or libraries; you will also see the key options that change runtime behavior in Node, plus a short guide to monorepos and project references.

Choosing ESM or CommonJS and why it matters

Node can execute JavaScript in two module systems. ESM uses import and export with URL like resolution rules and file extensions. CommonJS uses require and module.exports with different resolution rules. This choice affects your package.json, file extensions, how TypeScript emits modules, and how tools resolve dependencies.

Package shape and file extensions

For ESM, mark the package as a module and prefer .mts or .ts inputs that compile to .mjs or .js with ESM syntax. For CommonJS, either omit the package type or set it to commonjs, then compile to .cjs or .js with CJS syntax. You can also publish dual entry points using exports maps with conditions.

{
  "name": "my-app",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
πŸ’‘ When you use ESM in Node, always include file extensions in import paths once compiled, or rely on a bundler that rewrites them. Omitting extensions causes runtime errors in pure Node ESM.

ESM and CommonJS APIs differ

In ESM you cannot use require directly and you access __dirname through import.meta.url. In CommonJS you use require and have __dirname available. The following helper gives you a portable directory name in ESM.

// file: src/dir.ts (ESM)
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
export const here = dirname(fileURLToPath(import.meta.url));

Quick comparison

Aspect ESM CommonJS
Package marker "type": "module" "type": "commonjs" or omitted
Syntax import and export require and module.exports
Extensions at runtime Required; use .js or .mjs Optional; resolution is more permissive
Interop createRequire for CJS; default export wrapping varies require on transpiled ESM may need .default
TypeScript module NodeNext or ESNext CommonJS
⚠️ Mixing ESM and CommonJS in one package complicates resolution and testing. If you must support both, generate two builds under separate folders and use an exports map with import and require conditions.

Compiler options that affect runtime behavior

Several tsconfig.json options change what Node executes, not only how TypeScript type checks. The module target, module resolution strategy, and JSX or decorators settings all influence emitted code and import paths. Choose settings that match your chosen module system and your runtime environment.

module and moduleResolution

Use module: "NodeNext" with moduleResolution: "NodeNext" for projects that follow Node’s ESM and CJS rules in the same repo. This lets TypeScript mirror Node’s behavior for .ts, .mts, .cts inputs and their emitted files. For strictly ESM without CJS inputs, module: "ESNext" with moduleResolution: "Bundler" suits bundler driven apps.

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "resolveJsonModule": true,
    "allowJs": false
  }
}

isolatedModules and verbatimModuleSyntax

isolatedModules forces each file to be emit ready by itself, which aligns with many transpilers. verbatimModuleSyntax tells the compiler to keep your imports and exports as written; unused imports that carry types only must be written with import type.

{
  "compilerOptions": {
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

Path mapping and resolution

Path aliases improve ergonomics but can break at runtime if Node cannot resolve them. When you use paths, either compile with outDir and run the output where paths are rewritten, or use a resolver at runtime. Keep aliases shallow and stable.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@lib/*": ["src/lib/*"]
    }
  }
}
πŸ’‘ Prefer relative imports within a package and reserve path aliases for cross package references in a monorepo. This keeps local refactors simple.

Emit format and sourceMap

Enable sourceMap for debugging and set inlineSources so error stacks map back to TypeScript during local development. Ensure your process manager enables source maps at runtime through flags or environment variables when needed.

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSources": true
  }
}

Strictness, target, and lib settings

Strict types give better feedback in Node services and libraries. The JavaScript features you can use depend on the target you emit and the lib you include for type checking. Choose the newest target your runtime supports, then include DOM libraries only when you actually run in a browser or a hybrid environment.

strict and friends

strict enables several checks at once. You can refine behavior with targeted flags such as noUncheckedIndexedAccess for safer dictionary lookups and exactOptionalPropertyTypes for precise optional fields.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

target for your Node version

Pick a target that matches your minimum Node runtime. Newer Node versions support modern syntax and built ins, which lets you compile less and run faster.

Minimum Node Suggested target Notes
Node 16 ES2021 Top level await in ESM; Promise.any
Node 18 ES2022 Error cause; class fields
Node 20+ ES2023 or ESNext Array findLast; structuredClone

lib picks for Node

For a pure Node service, include ES2022 or newer libs and DOM only when you truly access browser APIs. Many projects choose ["ES2022"] or ["ES2023"] for server code.

{
  "compilerOptions": {
    "lib": ["ES2023"],
    "skipLibCheck": true
  }
}
⚠️ Adding DOM to lib masks accidental use of browser globals in server code. Keep server and browser targets in separate tsconfig files when you share types across environments.

Monorepos and project references

Project references let TypeScript compile multiple packages in a monorepo in topological order. This improves build speed and keeps type boundaries clear. Arrange each package with its own tsconfig.json, then create a root tsconfig.json with references so tsc -b can coordinate builds.

Directory layout

my-workspace/
β”œβ”€ package.json
β”œβ”€ tsconfig.base.json
β”œβ”€ packages/
β”‚  β”œβ”€ utils/
β”‚  β”‚  β”œβ”€ package.json
β”‚  β”‚  β”œβ”€ tsconfig.json
β”‚  β”‚  └─ src/…
β”‚  └─ api/
β”‚     β”œβ”€ package.json
β”‚     β”œβ”€ tsconfig.json
β”‚     └─ src/…

Per package tsconfig.json

Each package extends a base and sets an outDir. Mark composite builds to enable references, and set declaration so downstream packages consume types.

// packages/utils/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "include": ["src"]
}

Root tsconfig.json with references

The build config sits at the workspace root and points to each package. The -b flag uses this to build all packages in dependency order and to watch them efficiently.

// tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/utils" },
    { "path": "./packages/api" }
  ]
}
πŸ’‘ Use tsc -b --watch at the root for a fast incremental developer loop across all packages. It tracks which leaf changed and rebuilds affected dependents only.

Publishing and exports maps

For publishable packages, emit types into dist and define an exports map that points to ESM and CJS entries. This keeps consumers on stable paths and lets Node pick the right file.

// packages/utils/package.json
{
  "name": "@scope/utils",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}

Testing and tooling in a monorepo

Keep local package links through your package manager’s workspace support so tooling resolves source during development. Run tests at the package level for quick feedback, then run a workspace wide test pass in continuous integration.