TypeScript with Node.js
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:
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/expressThen create tsconfig.json. Node.js runs the .ts files itself, so tsc only checks them (noEmit):
{
"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:
{
"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:
// 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));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:
// 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")}`);
}node src/report.tsRead 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.
// 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):
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{"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, soreq.params.idis known to be a string. Route parameters are always strings; convert them yourself.- In the
/saleshandler,_reqandresneed no annotations: TypeScript infers them fromapp.get. req.bodyis typedanyby Express, so it is assigned tounknownand checked withisOrderbefore use. Never trust a request body because of a type annotation.res.json(...)followed byreturnends 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:
// 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"]. Thenprocess,Bufferandnode:imports have no types in TypeScript 7. - Trusting
req.bodyorJSON.parse. Both areany. Store the value asunknownand validate it. - Treating
process.env.Xas a string. It isstring | undefined; provide a default or fail with a clear message. - Using
enumor parameter properties. Node.js type stripping rejects them;erasableSyntaxOnlywarns you first. - Forgetting that Node.js never type-checks. Run
npm run checkin CI and before every deploy.
Key points
- For Node.js 26, use
module: nodenext,types: ["node"]andnoEmit, and let Node.js run the.tsfiles. - Built-in modules use the
node:prefix and are typed by@types/node. - Data from files, requests and environment variables starts as
unknownorstring | undefined; validate it. - Express 5 is typed by
@types/express;Request<Params>types route parameters, and async errors are handled automatically. node --watchrestarts on change, andnode --testruns 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
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?
Global type packages must be listed in types, after installing @types/node.
2. What is the type of process.env.PORT?
Any environment variable may be missing, so its type includes undefined.
3. Why is req.body assigned to unknown in the POST handler?
Treating request bodies as unknown forces validation before use.
4. What does node --watch src/server.ts do?
--watch restarts the process on file changes; it does not check types.
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
