TypeScript with Node.js

TypeScript is a natural fit for server code, scripts and command-line tools on Node.js. This lesson sets up a Node project from scratch, uses Node's built-in modules with full types, builds a small typed HTTP server, and covers the ways to run TypeScript in development and production.

Project setup

$ terminal
mkdir notes-api && cd notes-api
npm init -y
npm install --save-dev typescript @types/node tsx
npx tsc --init

@types/node provides types for everything Node ships with — fs, path, http, process and so on. tsx runs .ts files directly during development.

In package.json, opt into ES modules and add scripts:

JSON package.json (excerpt)
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "typecheck": "tsc --noEmit"
  }
}

And a tsconfig.json that follows Node's module rules:

JSON tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src"]
}

The "types": ["node"] line matters: since TypeScript 6.0, global type packages aren't loaded unless listed, so without it names like process and Buffer would be unknown.

Using built-in modules

Node's APIs are fully typed, so you get autocomplete and checking for file system calls, paths and environment variables:

TS src/stats.ts
import { readFile, writeFile, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

async function main() {
  const dir = await mkdtemp(join(tmpdir(), "ts-"));
  const file = join(dir, "notes.txt");
  await writeFile(file, "first line\nsecond line\nthird line\n");

  const text = await readFile(file, "utf8");
  const lines = text.trim().split("\n");
  console.log(`${lines.length} lines, longest: ${Math.max(...lines.map(l => l.length))} chars`);
}

main();
Output
3 lines, longest: 11 chars

Because "utf8" was passed, TypeScript knows readFile returns a string rather than a Buffer — the Node type definitions use overloads (lesson 16) to model that.

Environment variables

process.env values are typed string | undefined, because any variable might be missing. Validate them once at startup:

TS src/config.ts
function requireEnv(name: string, fallback?: string): string {
  const value = process.env[name] ?? fallback;
  if (value === undefined) {
    throw new Error(`Missing environment variable ${name}`);
  }
  return value;
}

export const config = {
  port: Number(requireEnv("PORT", "3000")),
  nodeEnv: requireEnv("NODE_ENV", "development"),
};

console.log(config);
Output
{ port: 3000, nodeEnv: 'development' }

Every other file imports config and gets proper number and string types, instead of sprinkling process.env.X! everywhere.

A typed HTTP server

Node's built-in http module is enough to show the pattern. Frameworks such as Express, Fastify and Hono follow the same ideas with more convenience:

TS src/server.ts
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

interface Note {
  id: number;
  text: string;
}

const notes: Note[] = [{ id: 1, text: "Learn TypeScript" }];

function sendJson(res: ServerResponse, status: number, body: unknown) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
}

async function readBody(req: IncomingMessage): Promise<unknown> {
  let raw = "";
  for await (const chunk of req) raw += chunk;
  return raw ? JSON.parse(raw) : undefined;
}

const server = createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/notes") {
    return sendJson(res, 200, notes);
  }
  if (req.method === "POST" && req.url === "/notes") {
    const body = await readBody(req);
    if (typeof body !== "object" || body === null || typeof (body as { text?: unknown }).text !== "string") {
      return sendJson(res, 400, { error: "text is required" });
    }
    const note: Note = { id: notes.length + 1, text: (body as { text: string }).text };
    notes.push(note);
    return sendJson(res, 201, note);
  }
  sendJson(res, 404, { error: "not found" });
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));

Notice that the request body is treated as unknown and validated before use — the same principle as lesson 37. With Express you'd install @types/express and get typed req/res objects; Fastify and Hono ship their own types.

Running TypeScript on Node

ApproachCommandGood for
Compile, then run JStsc && node dist/server.jsProduction; fastest startup
tsxtsx watch src/server.tsDevelopment with auto-restart
Node's built-in type strippingnode src/server.tsScripts and simple apps (Node 22.18+ / 23.6+)

Node's built-in support only removes types; it doesn't check them, and it can't handle syntax that generates code — enums, namespaces and parameter properties. If you plan to rely on it, enable "erasableSyntaxOnly": true in tsconfig.json so the compiler flags those constructs. Whichever way you run code, keep tsc --noEmit in your workflow to catch type errors.

Common gotchas

  • Import extensions. With module: nodenext and "type": "module", relative imports need .js — import { config } from "./config.js" — even though the source file is config.ts.
  • __dirname in ES modules. It doesn't exist; use import.meta.dirname (Node 20.11+).
  • "Cannot find name 'process'". Either @types/node isn't installed, or it's missing from "types" in tsconfig.json.