TypeScript with Node.js

TypeScript · Lesson 25 of 28 7 min read Updated

Node.js is where many TypeScript developers spend most of their time: APIs, scripts, command-line tools and background jobs. Since Node.js can now run .ts files directly, the setup is simpler than ever, but you still need to know how to type Node's own modules, environment variables, incoming JSON and web frameworks like Express.

In this lesson you will set up a Node.js project, write a typed script that reads a file, turn it into a small Express 5 API, and test it with Node's built-in test runner. It brings together Run TypeScript in Node.js, Modules & Imports and any, unknown & never.

Project setup

Create a project with ES modules, TypeScript, Node's type definitions and Express:

Terminal
mkdir shop-api
cd shop-api
npm init -y
npm pkg set type=module
npm install express
npm install --save-dev typescript @types/node @types/express

Then create tsconfig.json. Node.js runs the .ts files itself, so tsc only checks them (noEmit):

JSON
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "types": ["node"],
    "noEmit": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "rewriteRelativeImportExtensions": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

And add scripts to package.json:

JSON
{
  "scripts": {
    "dev": "node --watch src/server.ts",
    "start": "node src/server.ts",
    "check": "tsc",
    "test": "node --test src/orders.test.ts"
  }
}

node --watch restarts the server whenever you save a file. Run npm run check in a second terminal (or rely on your editor) to see type errors, because Node.js itself never checks types.

Typed business logic

Keep the logic that does not depend on Node.js or Express in its own module. It is easy to test and, as you can see here, it even runs in the browser:

TypeScriptRun it yourself
// src/orders.ts
export type Order = { id: number; customer: string; city: string; amount: number };

export function isOrder(value: unknown): value is Order {
  return (
    typeof value === "object" && value !== null &&
    "id" in value && typeof value.id === "number" &&
    "customer" in value && typeof value.customer === "string" &&
    "city" in value && typeof value.city === "string" &&
    "amount" in value && typeof value.amount === "number"
  );
}

export function salesByCity(orders: readonly Order[]): Map<string, number> {
  const totals = new Map<string, number>();
  for (const order of orders) {
    totals.set(order.city, (totals.get(order.city) ?? 0) + order.amount);
  }
  return totals;
}

// quick check
const sample: unknown[] = [
  { id: 101, customer: "Aarav", city: "Pune", amount: 1299 },
  { id: 102, customer: "Meera", city: "Kochi", amount: 450 },
  { id: "x" },
];
const valid = sample.filter(isOrder);
console.log(valid.length, salesByCity(valid));
Output
2 Map(2) { 'Pune' => 1299, 'Kochi' => 450 }

isOrder is a type guard (see Type Guards & Assertions). Every piece of data that enters the program from outside, whether a file, a request body or an environment variable, should pass through a check like this before your code trusts it.

A typed script with node: modules

Node's built-in modules are imported with the node: prefix, and their types come from @types/node. This script reads data/orders.json and prints sales per city:

TypeScript
// src/report.ts
import { readFile } from "node:fs/promises";
import { isOrder, salesByCity, type Order } from "./orders.ts";

const path = process.argv[2] ?? "data/orders.json";
const raw: unknown = JSON.parse(await readFile(path, "utf8"));

if (!Array.isArray(raw)) {
  console.error("Expected an array of orders");
  process.exit(1);
}

const orders: Order[] = raw.filter(isOrder);
console.log(`Read ${orders.length} orders from ${path}`);

for (const [city, total] of salesByCity(orders)) {
  console.log(`${city.padEnd(8)} ₹${total.toLocaleString("en-IN")}`);
}
Terminal
node src/report.ts
Output
Read 4 orders from data/orders.json
Pune     ₹3,399
Kochi    ₹450
Jaipur   ₹799

Several types are doing quiet work here. readFile(path, "utf8") returns Promise<string> because you passed an encoding (without it, you get a Buffer). process.argv[2] is string | undefined because of noUncheckedIndexedAccess, so ?? supplies a default. JSON.parse returns any, so the result is stored as unknown and checked. And top-level await works because the file is an ES module.

Environment variables deserve the same care: process.env.PORT has the type string | undefined. Convert and default it explicitly, as the server below does.

An Express 5 API

Express does not ship its own types, so they come from @types/express on DefinitelyTyped. Express 5 also catches errors from async route handlers automatically, which Express 4 did not.

TypeScript
// src/server.ts
import express, { type Request, type Response } from "express";
import { readFile } from "node:fs/promises";
import { isOrder, salesByCity, type Order } from "./orders.ts";

const app = express();
app.use(express.json());

const port = Number(process.env.PORT ?? 3000);

async function loadOrders(): Promise<Order[]> {
  const raw: unknown = JSON.parse(await readFile("data/orders.json", "utf8"));
  return Array.isArray(raw) ? raw.filter(isOrder) : [];
}

app.get("/orders/:id", async (req: Request<{ id: string }>, res: Response) => {
  const orders = await loadOrders();
  const order = orders.find((o) => o.id === Number(req.params.id));
  if (!order) {
    res.status(404).json({ error: `Order ${req.params.id} not found` });
    return;
  }
  res.json(order);
});

app.get("/sales", async (_req, res) => {
  const totals = salesByCity(await loadOrders());
  res.json(Object.fromEntries(totals));
});

app.post("/orders", (req: Request, res: Response) => {
  const body: unknown = req.body;
  if (!isOrder(body)) {
    res.status(400).json({ error: "Invalid order" });
    return;
  }
  res.status(201).json({ saved: body.id });
});

app.listen(port, () => {
  console.log(`Shop API on http://localhost:${port}`);
});

Start it with npm run dev and try it with curl (built into Windows 10 and later, macOS and Linux):

Terminal
curl http://localhost:3000/orders/102
curl http://localhost:3000/orders/999
curl http://localhost:3000/sales
curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"x\"}" http://localhost:3000/orders
Output
{"id":102,"customer":"Meera","city":"Kochi","amount":450}
{"error":"Order 999 not found"}
{"Pune":3399,"Kochi":450,"Jaipur":799}
{"error":"Invalid order"}

Points to notice:

  • Request<{ id: string }> types the route parameters, so req.params.id is known to be a string. Route parameters are always strings; convert them yourself.
  • In the /sales handler, _req and res need no annotations: TypeScript infers them from app.get.
  • req.body is typed any by Express, so it is assigned to unknown and checked with isOrder before use. Never trust a request body because of a type annotation.
  • res.json(...) followed by return ends the handler cleanly in the "not found" branch.

Testing with node:test

Node.js has a built-in test runner, and it works with .ts files through type stripping:

TypeScript
// src/orders.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { salesByCity, isOrder } from "./orders.ts";

test("adds sales per city", () => {
  const totals = salesByCity([
    { id: 1, customer: "A", city: "Pune", amount: 100 },
    { id: 2, customer: "B", city: "Pune", amount: 250 },
  ]);
  assert.equal(totals.get("Pune"), 350);
});

test("rejects broken orders", () => {
  assert.equal(isOrder({ id: "x" }), false);
});

Run it with npm test. Node.js prints a line for each test and a summary with the number passed and failed. Because the tests are TypeScript too, a test that calls salesByCity with a wrong shape fails npm run check before it even runs.

Common mistakes

  • Forgetting "types": ["node"]. Then process, Buffer and node: imports have no types in TypeScript 7.
  • Trusting req.body or JSON.parse. Both are any. Store the value as unknown and validate it.
  • Treating process.env.X as a string. It is string | undefined; provide a default or fail with a clear message.
  • Using enum or parameter properties. Node.js type stripping rejects them; erasableSyntaxOnly warns you first.
  • Forgetting that Node.js never type-checks. Run npm run check in CI and before every deploy.

Key points

  • For Node.js 26, use module: nodenext, types: ["node"] and noEmit, and let Node.js run the .ts files.
  • Built-in modules use the node: prefix and are typed by @types/node.
  • Data from files, requests and environment variables starts as unknown or string | undefined; validate it.
  • Express 5 is typed by @types/express; Request<Params> types route parameters, and async errors are handled automatically.
  • node --watch restarts on change, and node --test runs TypeScript test files directly.

Exercise

Add a route GET /orders?city=Pune to the shop API that returns only the orders for that city. Read the query with req.query.city, make sure it is a non-empty string (it may be missing, or an array if the parameter appears twice), and return a 400 error with a helpful message otherwise. Write the handler with types.

Show solution
TypeScript
app.get("/orders", async (req: Request, res: Response) => {
  const city = req.query.city;
  if (typeof city !== "string" || city.trim() === "") {
    res.status(400).json({ error: "Add ?city=<name>, for example ?city=Pune" });
    return;
  }
  const orders = await loadOrders();
  const matching = orders.filter((o) => o.city.toLowerCase() === city.toLowerCase());
  res.json(matching);
});

req.query.city can be a string, an array or undefined, so the typeof check narrows it to string before use. Requesting /orders?city=pune now returns Aarav's and Farhan's orders.

Quick Quiz

1. Which tsconfig setting loads the types for process and node:fs in TypeScript 7?

2. What is the type of process.env.PORT?

3. Why is req.body assigned to unknown in the POST handler?

4. What does node --watch src/server.ts do?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call