# 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.

Backend with Node.js roadmap · Foundations · 15 min read · https://levelcode.org/roadmaps/node-backend/node-typescript

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](https://levelcode.org/roadmaps/frontend/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:

| Option | Why |
|---|---|
| `module`/`moduleResolution: nodenext` | Resolve imports exactly as Node does, ESM and CommonJS included |
| `target: es2023`, `lib: es2023` | Node 22 supports modern JavaScript; no down-levelling needed. No `dom`: there's no browser |
| `strict` | Null checks and the rest. Non-negotiable for new code |
| `noUncheckedIndexedAccess` | `arr[0]` and `record[key]` are typed as possibly `undefined`, which they are |
| `verbatimModuleSyntax` | Forces `import type` for type-only imports, which type stripping needs to remove them safely |
| `erasableSyntaxOnly` | Rejects syntax Node's type stripping can't run |
| `allowImportingTsExtensions` + `rewriteRelativeImportExtensions` | Write `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.

> [!TIP]
> Run `node --enable-source-maps dist/server.js` (or set `NODE_OPTIONS=--enable-source-maps`) so error stack traces from the compiled code map back to your TypeScript files and line numbers.

## 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`.

**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.

## Quiz

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

   A. Node refuses to run the file
   B. Node converts the number to a string
   C. Node logs a type warning and continues
   D. Node runs it without complaint; only tsc would report the error

   **Answer: D.** Type stripping deletes annotations without checking them. Type errors are caught by tsc, in your editor and in CI.

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

   A. Enums generate runtime JavaScript, which Node's type stripping can't produce
   B. Enums are deprecated in TypeScript
   C. Enums don't work with ES modules
   D. Enums are slower than objects

   **Answer: A.** Type stripping can only remove type syntax. An enum compiles to a real object, so it needs a transformation step that strip-only mode doesn't do.

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

   A. number
   B. string
   C. string | undefined
   D. any

   **Answer: C.** Environment variables are always strings, and may be missing. Convert and validate them, for example with z.coerce.number() at startup.

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

   A. Tests can import the app and send it requests without starting a real server
   B. Node requires two entry files
   C. It makes the build faster
   D. TypeScript can't compile files that call listen()

   **Answer: A.** A side-effect-free app module can be imported by tests and exercised in-process, while server.ts owns the port, signals and shutdown.
