A lot of teams adopt TypeScript on the frontend first and then bolt it onto Node services later. The result is usually a backend that compiles, but still has the same runtime surprises as before — bad request bodies, missing env vars, helpers that quietly accept any, middleware that mutates req in ways the types never see.

The good news is that Node and TypeScript have grown up together. The tooling is fast, ESM works, the runtime understands type stripping in newer versions, and Zod-style schemas let you push contracts all the way to the request boundary. The bad news is that none of it helps until you actually wire it up with intent.

This is what that wiring looks like in practice.

The Toolchain Question Has A Boring Answer Now

For a long time, "how do I run TypeScript on Node?" was a tab-burning question. Today it's three options and one decision:

  • tsx — fast, ESM/CJS compatible, drop-in. node --import tsx src/index.ts works on Node 20+ without configuration (the tsx/esm ESM-only loader still works for ESM-only projects). Use it for dev and scripts.
  • ts-node — older, slower, more configurable. Still around for legacy projects with strict ESM/CJS quirks.
  • tsc then node dist/index.js — what you ship to production. Build once, run a plain .js file.
  • Native Node TypeScript stripping — Node 22.6 added --experimental-strip-types; Node 22.18 / 24.3 unflagged it. On modern Node you can node app.ts directly with no loader at all, as long as your code doesn't use enums or namespaces (use --experimental-transform-types for those, or stick with tsx).

For a new service the path is: tsx for npm run dev, tsc -p tsconfig.build.json for the production image, and node dist/index.js to start. That's it. You don't need a bundler unless you're targeting Lambda or the edge.

JSON package.json
{
  "type": "module",
  "scripts": {
    "dev": "node --import tsx --watch src/index.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
}

That --watch is the built-in Node watcher — no nodemon needed. Pair it with tsx and your dev loop is sub-second restarts on every save.

ESM, CJS, And The One Setting That Saves Hours

Pick one module system and commit. New services should be "type": "module" (ESM). The friction is real but shrinking: most popular libraries ship dual builds, and import from CJS works for default exports.

The recurring trap is mixing them. If your tsconfig.json says "module": "NodeNext" and "moduleResolution": "NodeNext", TypeScript will force you to write .js extensions in relative imports — which feels weird in a .ts file but is exactly what the runtime needs after compilation:

TypeScript src/server.ts
import { createApp } from './app.js'; // yes, .js even though the file is app.ts

If you skip those extensions, the build works in dev (where tsx is forgiving) and breaks in production (where Node is not). It's the single most common "works on my machine" bug in modern Node + TS.

Type-Safe Express Requests Without any

Express gives you req as Request<...> with default generics. The moment you attach req.user from auth middleware or req.context from a request-id middleware, the types fall behind. The clean fix is declaration merging in a single types file the rest of the project picks up automatically.

TypeScript src/types/express.d.ts
import 'express';

declare module 'express-serve-static-core' {
  interface Request {
    user?: { id: string; role: 'admin' | 'member' };
    requestId: string;
  }
}

Now every handler knows req.requestId is a string and req.user may exist. No casts, no as any, no helper that pretends to be type-safe. Fastify users get a similar pattern via FastifyRequest augmentation; the principle is identical.

Inline diagram showing the trust boundary between an untyped HTTP request and a typed application core, with Zod-inferred types flowing from a single schema definition into both runtime parsing and compile-time inference.
Types are the contract inside the app. Schemas are the contract at the network edge.

One Schema, Two Outputs: Runtime Check + Inferred Type

The biggest leverage point in a TypeScript backend is the place where untrusted JSON becomes trusted data. JSON.parse(req.body) as User is a lie — TypeScript just shrugs and trusts you. Zod, Valibot, and similar libraries fix this by making the schema and the type the same definition.

TypeScript src/routes/users.ts
import { z } from 'zod';
import type { Request, Response } from 'express';

const CreateUser = z.object({
  email: z.email(),                                  // z.string().email() in Zod 3
  name: z.string().min(1).max(120),
  role: z.enum(['admin', 'member']).default('member'),
});

type CreateUserInput = z.infer<typeof CreateUser>;

export async function createUser(req: Request, res: Response) {
  const parsed = CreateUser.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.flatten() });
  }
  const input: CreateUserInput = parsed.data;
  // ...persist input, send response
}

Two things are happening here. The schema validates at runtime (safeParse so the handler controls the error). And z.infer produces a TypeScript type from that same schema, so the rest of the codebase has a real type to work with — one that can never drift from the validation rules because they're the same object.

If your client and server share a workspace, you can export the schema from a shared package and the frontend gets the same validated input shape. That's the whole "end-to-end type safety" pitch, with no extra magic.

Strict Mode Is Not Optional, And Neither Are Env Vars

Two tsconfig settings carry most of the weight on the backend: "strict": true and "noUncheckedIndexedAccess": true. The first makes nullability honest. The second turns arr[0] into T | undefined, which is what it actually is — and forces you to handle the empty case before the bug ships.

Env vars deserve the same treatment. process.env.DATABASE_URL is string | undefined, full stop. The fix is to validate the whole environment once at startup with the same Zod-style schema and export a typed object the rest of the app imports:

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

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

export const env = Env.parse(process.env);

If a required variable is missing, the process dies before it ever takes a request. That is exactly the failure mode you want.

The Honest Summary

A TypeScript Node service earns its keep at three boundaries: the request body, the environment, and the request object you augment as it moves through middleware. Get those three right and the rest of the codebase becomes pleasant to refactor — types flow from real validation, not from hopeful annotations.

If you only do one thing this week, pick a service and add a z.infer-driven schema for one endpoint and one Env.parse for startup. You will feel the difference in the next deploy 👊