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"
}
}
}
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 |
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/*"]
}
}
}
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
}
}
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" }
]
}
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.