NodeJSSetting Up a TS Node Project

Setting Up a TS Node Project

Setting up TypeScript for Node is mostly about one file: tsconfig.json, which tells the compiler what to compile, how strictly to check it, and what JavaScript to emit. Get a few key options right — strict, the module/target settings, outDir/rootDir, and the Node type definitions — and the rest follows. This page walks through installing the toolchain, the tsconfig.json options that actually matter, the @types/node package, the build-and-run workflow, and the project layout that separates source from compiled output.

Install the toolchain

Bash
npm init -y
npm install --save-dev typescript @types/node    # compiler + Node's type definitions
npx tsc --init                                    # generate a starter tsconfig.json
You need `typescript` (the compiler) and `@types/node` (types for `fs`, `process`, etc.) as devDependencies
Two dev dependencies get you started. **`typescript`** provides the `tsc` compiler (installed locally, not globally, so the version is pinned per project and reproducible in CI). **`@types/node`** provides the type definitions for Node's built-in modules — without it, `process`, `fs`, `Buffer`, `__dirname`, and friends are untyped and the compiler complains. Both are `devDependencies` because they're only needed to *build*, not to run the compiled JavaScript. `npx tsc --init` scaffolds a heavily-commented `tsconfig.json` you then trim to the options that matter. Match `@types/node`'s major version to your Node version so the types reflect the APIs actually available.
The tsconfig.json options that matter

tsconfig.json

JSON
{
  "compilerOptions": {
    "target": "ES2022",          // JS version to emit (match your Node version's support)
    "module": "NodeNext",        // module system — NodeNext for modern ESM/CJS interop
    "moduleResolution": "NodeNext",

    "strict": true,              // ⭐ the single most important option — turn it ON

    "outDir": "./dist",          // where compiled .js goes
    "rootDir": "./src",          // where your .ts source lives

    "esModuleInterop": true,     // smooth default imports from CommonJS packages
    "skipLibCheck": true,        // don't type-check .d.ts files in node_modules (faster)
    "sourceMap": true,           // emit .js.map so stack traces point to .ts lines
    "declaration": false         // set true if publishing a library (emit .d.ts)
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Set `target`/`module` to match modern Node, point `rootDir`→`outDir` (src→dist), and enable interop + source maps
A handful of options do the heavy lifting. **`target`** controls which JavaScript version `tsc` emits — set it to what your Node version supports (e.g. `ES2022` for Node 18+) so you're not needlessly downleveling. **`module`/`moduleResolution`** of `NodeNext` give you correct modern ESM/CommonJS resolution. **`outDir`** and **`rootDir`** define the build: source in `src/`, compiled output in `dist/` (keeping them separate is the standard layout). **`esModuleInterop`** makes `import x from 'cjs-pkg'` work cleanly against CommonJS packages. **`skipLibCheck`** skips type-checking dependency `.d.ts` files for a big speed win. **`sourceMap`** is covered below. Set **`declaration: true`** only when publishing a library (so consumers get types). `include`/`exclude` scope what gets compiled.
strict mode — turn it on from day one

TS
// With "strict": true, these are caught:
function len(s: string) { return s.length }
len(maybeUndefined)        // ❌ strictNullChecks: Argument might be 'undefined'

let x                      // ❌ noImplicitAny: 'x' implicitly has type 'any'

class Box { value: number } // ❌ strictPropertyInitialization: 'value' not initialized
Enable `strict` before you write code — retrofitting it onto an existing untyped codebase is painful
**`strict: true`** is the option that makes TypeScript actually *worth using*. It's an umbrella that turns on a bundle of checks, most importantly **`strictNullChecks`** (`null`/`undefined` aren't silently assignable to every type — the single biggest bug-catcher, since it forces you to handle the "missing value" case) and **`noImplicitAny`** (a value the compiler can't type becomes an error instead of a silent `any`). Turn it on **from the very first commit**: starting strict costs nothing, but enabling it later on a large untyped codebase surfaces hundreds of errors at once and is a miserable retrofit. If you ever inherit a non-strict project, migrate incrementally (enable one flag at a time), but for anything new, strict from day one is non-negotiable for getting TypeScript's real value.
Build and run workflow

package.json scripts

JSON
{
  "type": "module",
  "scripts": {
    "build": "tsc",                       // compile src/ → dist/
    "start": "node dist/index.js",        // run the COMPILED output in production
    "dev": "tsx watch src/index.js",      // run TS directly + restart on change (dev)
    "typecheck": "tsc --noEmit"           // check types WITHOUT emitting (great for CI)
  }
}
`tsc` builds to `dist/` for production; `tsc --noEmit` type-checks in CI; a watch runner gives a fast dev loop
Three workflows cover everything. For **production**, `npm run build` runs `tsc` to compile `src/` into `dist/`, and you run the compiled `node dist/index.js` — the runtime never touches TypeScript. For **CI**, `tsc --noEmit` type-checks the whole project *without* producing files, which is the fast, definitive "do the types pass?" gate to run alongside your tests and lint. For **development**, you don't want to rebuild manually on every save — use a watch-mode runner like [`tsx` or `ts-node`](/nodejs/ts-node-tsx) that compiles on the fly and restarts, giving an edit-save-see-result loop as quick as plain JavaScript. Keep these as `package.json` scripts so the commands are discoverable and consistent across the team and CI.
Project layout

Text
my-app/
   ├─ src/                ← your TypeScript source (rootDir)
   │   ├─ index.ts
   │   └─ lib/...
   ├─ dist/               ← compiled JavaScript output (outDir) — GIT-IGNORE this
   ├─ tsconfig.json
   ├─ package.json
   └─ .gitignore          ← contains: node_modules  dist
Keep `src/` and `dist/` separate, and git-ignore `dist/` — never commit compiled output or edit it by hand
The conventional layout keeps **source** (`src/`, your `.ts` files) and **build output** (`dist/`, the emitted `.js` + source maps) in separate directories, matching `rootDir` and `outDir`. Two rules keep this clean: **git-ignore `dist/`** (along with `node_modules`) — compiled output is a derived artifact, regenerated by `tsc` on every build and in CI, so committing it causes noisy diffs and stale-file bugs; and **never edit files in `dist/` by hand** — they're overwritten on the next build. When you publish a library, you *do* ship `dist/` (via the `files` field) but still don't commit it. This separation makes "clean and rebuild" trivial (delete `dist/`, run `tsc`) and keeps your repository to source only.
Next
Apply this to a real server — typing routes, middleware, and requests: [Typing an Express App](/nodejs/typescript-express).