Step 02 of 12

TypeScript on Node

Set up a TypeScript Node project: tsconfig for Node, running .ts files directly with type stripping, type-checking, building for production and project layout.

15 min 8 sections

On the backend, TypeScript earns its keep even more than in the browser: request bodies, database rows, configuration and third-party responses all pass through your code, and a type error in a rarely used path can sit undetected until it takes down production. This lesson sets up a TypeScript Node project the modern way: Node runs .ts files directly, the compiler checks types, and a build step produces plain JavaScript for production. It assumes you know TypeScript's syntax from the frontend roadmap's TypeScript essentials lesson.

Creating the project

bash
mkdir orders-api && cd orders-api
npm init -y
npm pkg set type=module
npm install --save-dev typescript @types/node
mkdir src
  • "type": "module" makes .js files ES modules.
  • typescript provides tsc, the type checker and compiler.
  • @types/node provides types for Node's built-in modules, so process.env and node:fs are typed.

Running TypeScript directly

Since Node 22.18 (and 23.6), Node runs .ts files natively through type stripping: it erases the type annotations and runs the JavaScript that's left, with no compile step.

ts
// src/hello.ts
type Greeting = { name: string; excited?: boolean }

function greet({ name, excited = false }: Greeting): string {
  return `Hello, ${name}${excited ? '!' : '.'}`
}

console.log(greet({ name: 'Ana', excited: true }))
bash
node src/hello.ts
text
Hello, Ana!

Two limits to know:

  1. Node doesn't check types. greet({ name: 42 }) runs happily. Type checking is a separate step with tsc (below), run in your editor and in CI.
  2. Only erasable syntax works. Type stripping can delete annotations, but can't rewrite code. TypeScript features that generate JavaScript are rejected: enum, namespace, and constructor parameter properties (constructor(private db: Db)). Use union types and objects instead of enums:
ts
// Instead of: enum Status { Pending, Paid }
export const Status = { Pending: 'pending', Paid: 'paid' } as const
export type Status = (typeof Status)[keyof typeof Status] // 'pending' | 'paid'

The erasableSyntaxOnly compiler option (TypeScript 5.8+) makes tsc flag these features for you.

Alternatives exist if you're on an older Node or need non-erasable syntax: tsx (npx tsx src/server.ts) runs any TypeScript with esbuild. Both approaches skip type checking at run time.

tsconfig.json for Node

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["es2023"],
    "types": ["node"],

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,

    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

What the important options do:

OptionWhy
module/moduleResolution: nodenextResolve imports exactly as Node does, ESM and CommonJS included
target: es2023, lib: es2023Node 22 supports modern JavaScript; no down-levelling needed. No dom: there's no browser
strictNull checks and the rest. Non-negotiable for new code
noUncheckedIndexedAccessarr[0] and record[key] are typed as possibly undefined, which they are
verbatimModuleSyntaxForces import type for type-only imports, which type stripping needs to remove them safely
erasableSyntaxOnlyRejects syntax Node's type stripping can't run
allowImportingTsExtensions + rewriteRelativeImportExtensionsWrite import './db.ts' (what Node needs at dev time); tsc rewrites it to ./db.js in the build

That last pair needs a word. In ES modules, relative imports must include the file extension. When Node runs your source directly, the file really is db.ts, so you write:

ts
import { db } from './db.ts'
import type { Order } from './types.ts'

and the compiled output in dist/ imports ./db.js.

Type-checking and building

Add scripts to package.json:

json
{
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}
  • npm run dev runs the source directly, restarting on every change, with variables from .env.
  • npm run typecheck checks every file without writing output. Run it in CI on every pull request; your editor runs the same checks as you type.
  • npm run build compiles src/ to JavaScript in dist/, with source maps so stack traces point at your .ts lines.
  • npm start runs the compiled output, which is what production runs.

Why build for production when Node can run .ts? A build fails loudly on type errors before deploy, the output starts slightly faster, and production images then don't need TypeScript at all. Some teams do run .ts directly in production; if you do, make npm run typecheck a required CI step.

Typing the boundaries

TypeScript only knows what you tell it. Inside your code, types flow automatically; at the boundaries, where data enters from outside, it has no idea what's really there:

ts
const port = process.env.PORT            // string | undefined
const body = await request.json()        // any (or unknown)
const rows = await db.query('SELECT ...') // whatever the driver claims

process.env.PORT is string | undefined, never a number. A JSON body is untyped until something checks it. A type annotation on such values is a claim, not a check:

ts
const order = (await request.json()) as Order // compiles, but nothing verified it

The rest of this roadmap validates every boundary at runtime with Zod schemas, which produce TypeScript types from the same definition, so the claim and the check can't disagree. Configuration is the first boundary, and validating it at startup turns "undefined is not a function" in the middle of the night into a clear error at deploy time:

ts
// src/config.ts
import { z } from 'zod'

const Env = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.url(),
})

export const config = Env.parse(process.env)
export type Config = z.infer<typeof Env>

z.coerce.number() converts the string from the environment, and Env.parse throws at startup, listing every missing or invalid variable, before the server accepts a single request.

Project layout

A layout that scales from a small API to a large one:

text
orders-api/
  package.json
  package-lock.json
  tsconfig.json
  .env.example          # documents required variables (no secrets); .env is in .gitignore
  src/
    server.ts           # entry point: starts listening, handles shutdown
    app.ts              # builds the app (routes, middleware), with no side effects; tests import it
    config.ts
    db.ts
    orders/
      routes.ts
      service.ts
      repository.ts
      schemas.ts
  test/
  dist/                 # build output, in .gitignore

Group code by feature (orders/, users/), not by technical layer (controllers/, models/), so a change to orders stays in one folder. Keep app.ts free of side effects such as listening on a port: tests then import the app and send it requests without starting a server.

Practice

Set up the orders-api project from this lesson:

  1. Create the project with the package.json settings, scripts and tsconfig.json above, and install zod.
  2. Add src/config.ts as shown, and a src/server.ts that imports the config and logs listening on port <PORT> in <NODE_ENV> mode (no real server yet).
  3. Create .env with DATABASE_URL=postgres://localhost/orders and run npm run dev. Then remove DATABASE_URL and check that startup fails with a readable error.
  4. Add a file using an enum and see what npm run typecheck and node say. Replace it with an as const object.
  5. Run npm run build and npm start, and check that dist/ contains .js files whose imports end in .js.
Show solution
ts
// src/server.ts
import { config } from './config.ts'

console.log(`listening on port ${config.PORT} in ${config.NODE_ENV} mode`)
bash
echo 'DATABASE_URL=postgres://localhost/orders' > .env
npm run dev
# listening on port 3000 in development mode

Without DATABASE_URL, startup fails immediately:

text
ZodError: [
  {
    "expected": "string",
    "code": "invalid_type",
    "path": ["DATABASE_URL"],
    "message": "Invalid input: expected string, received undefined"
  }
]

For readable output in production, catch it and print z.prettifyError(error), then exit with code 1.

With an enum in src/status.ts, npm run typecheck reports:

text
src/status.ts:1:13 - error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

and running the file with node fails with an error explaining that TypeScript enum is not supported in strip-only mode. The as const object plus a derived union type gives the same safety with plain JavaScript underneath.

After npm run build, dist/server.js contains import { config } from "./config.js";: rewriteRelativeImportExtensions turned the .ts import into the .js file that actually exists in dist/, so npm start works with no TypeScript involved.

Key takeaways

  • Node 22.18+ runs .ts files directly by stripping types, but doesn't check them; run tsc --noEmit in your editor and CI.
  • Type stripping supports only erasable syntax: use as const objects and unions instead of enum, and set erasableSyntaxOnly.
  • Use module: nodenext, strict, noUncheckedIndexedAccess, verbatimModuleSyntax, and .ts import extensions rewritten at build time.
  • Build to dist/ for production with source maps enabled; commit the lock file and keep .env out of Git.
  • Types are claims at the boundaries: validate environment variables, request bodies and external data at runtime with Zod.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Node runs src/app.ts directly with type stripping, and the code passes a number where a string is declared. What happens?

  2. 2.Why does tsc reject enum when erasableSyntaxOnly is on?

  3. 3.What type does TypeScript give process.env.PORT?

  4. 4.Why keep app.ts (building routes and middleware) separate from server.ts (listening on a port)?

Finished reading?

Mark it complete to fill in your roadmap.