In the previous tutorial, we learned about TypeScript with React. Now let’s move to the backend — TypeScript with Node.js and Express.
By the end of this tutorial, you will know how to set up a Node.js + TypeScript project, create typed Express routes, write middleware, handle environment variables, and build a simple REST API.
Setting Up the Project
Create a new directory and initialize:
mkdir notes-api
cd notes-api
npm init -y
Install dependencies:
# Express and its types
npm install express
npm install --save-dev @types/express
# TypeScript and Node.js types
npm install --save-dev typescript @types/node
# tsx for running TypeScript directly
npm install --save-dev tsx
tsconfig.json
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
package.json Scripts
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Your First Express Server
Create src/index.ts:
import express, { Request, Response } from "express";
const app = express();
const PORT = 3000;
// Parse JSON request bodies
app.use(express.json());
app.get("/", (req: Request, res: Response) => {
res.json({ message: "Hello from TypeScript + Express" });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Run it:
npx tsx watch src/index.ts
Typing Request and Response
Express provides three main types:
import { Request, Response, NextFunction } from "express";
Typed Route Parameters
// Route: GET /users/:id
app.get("/users/:id", (req: Request<{ id: string }>, res: Response) => {
const userId = req.params.id; // string — typed!
res.json({ userId });
});
Typed Request Body
interface CreateNoteBody {
title: string;
content: string;
}
app.post("/notes", (req: Request<{}, {}, CreateNoteBody>, res: Response) => {
const { title, content } = req.body; // typed!
// title is string, content is string
res.status(201).json({ title, content });
});
The Request generic has this signature: Request<Params, ResBody, ReqBody, Query>.
Typed Query Parameters
interface SearchQuery {
q?: string;
page?: string;
limit?: string;
}
app.get("/search", (req: Request<{}, {}, {}, SearchQuery>, res: Response) => {
const query = req.query.q ?? "";
const page = parseInt(req.query.page ?? "1", 10);
const limit = parseInt(req.query.limit ?? "10", 10);
res.json({ query, page, limit });
});
Building a REST API
Let’s build a complete notes API with full CRUD operations.
Define the Types
// src/types.ts
export interface Note {
id: string;
title: string;
content: string;
createdAt: string;
updatedAt: string;
}
export interface CreateNoteInput {
title: string;
content: string;
}
export interface UpdateNoteInput {
title?: string;
content?: string;
}
In-Memory Storage
// src/store.ts
import { Note } from "./types.js";
const notes: Map<string, Note> = new Map();
export function getAllNotes(): Note[] {
return Array.from(notes.values());
}
export function getNoteById(id: string): Note | undefined {
return notes.get(id);
}
export function createNote(title: string, content: string): Note {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const note: Note = { id, title, content, createdAt: now, updatedAt: now };
notes.set(id, note);
return note;
}
export function updateNote(id: string, title?: string, content?: string): Note | undefined {
const note = notes.get(id);
if (!note) return undefined;
const updated: Note = {
...note,
title: title ?? note.title,
content: content ?? note.content,
updatedAt: new Date().toISOString(),
};
notes.set(id, updated);
return updated;
}
export function deleteNote(id: string): boolean {
return notes.delete(id);
}
Route Handlers
// src/routes/notes.ts
import { Router, Request, Response } from "express";
import { CreateNoteInput, UpdateNoteInput } from "../types.js";
import * as store from "../store.js";
const router = Router();
// GET /notes — list all notes
router.get("/", (req: Request, res: Response) => {
const notes = store.getAllNotes();
res.json(notes);
});
// GET /notes/:id — get a single note
router.get("/:id", (req: Request<{ id: string }>, res: Response) => {
const note = store.getNoteById(req.params.id);
if (!note) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(note);
});
// POST /notes — create a note
router.post("/", (req: Request<{}, {}, CreateNoteInput>, res: Response) => {
const { title, content } = req.body;
if (!title || !content) {
res.status(400).json({ error: "Title and content are required" });
return;
}
const note = store.createNote(title, content);
res.status(201).json(note);
});
// PUT /notes/:id — update a note
router.put("/:id", (req: Request<{ id: string }, {}, UpdateNoteInput>, res: Response) => {
const note = store.updateNote(req.params.id, req.body.title, req.body.content);
if (!note) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json(note);
});
// DELETE /notes/:id — delete a note
router.delete("/:id", (req: Request<{ id: string }>, res: Response) => {
const deleted = store.deleteNote(req.params.id);
if (!deleted) {
res.status(404).json({ error: "Note not found" });
return;
}
res.status(204).send();
});
export default router;
Main Server
// src/index.ts
import express from "express";
import notesRouter from "./routes/notes.js";
const app = express();
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
app.use(express.json());
app.use("/notes", notesRouter);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Typed Middleware
Middleware functions have access to req, res, and next:
import { Request, Response, NextFunction } from "express";
// Logging middleware
function logger(req: Request, res: Response, next: NextFunction): void {
console.log(`${req.method} ${req.path}`);
next();
}
// Error handling middleware (4 parameters)
function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error(err.stack);
res.status(500).json({ error: "Something went wrong" });
}
app.use(logger);
app.use("/notes", notesRouter);
app.use(errorHandler); // Must be last
Extending the Request Type
Sometimes middleware adds properties to the request. Use declaration merging to type them:
// src/types.ts
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
Now any middleware can set req.userId:
// Stub: replace with your actual JWT verification logic
function verifyToken(token: string): string {
// Example: decode a JWT and return the user ID
// In production, use a library like jsonwebtoken
return "user-id-from-token";
}
function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
res.status(401).json({ error: "No token provided" });
return;
}
// Verify token and extract user ID
req.userId = verifyToken(token);
next();
}
// In route handlers
app.get("/profile", authMiddleware, (req: Request, res: Response) => {
const userId = req.userId; // string | undefined — typed!
res.json({ userId });
});
Environment Variables
Type-Safe Environment Variables
Create a file to validate environment variables:
// src/env.ts
interface Env {
PORT: number;
DATABASE_URL: string;
JWT_SECRET: string;
NODE_ENV: "development" | "production" | "test";
}
function loadEnv(): Env {
const PORT = parseInt(process.env.PORT ?? "3000", 10);
const DATABASE_URL = process.env.DATABASE_URL;
const JWT_SECRET = process.env.JWT_SECRET;
const NODE_ENV = process.env.NODE_ENV as Env["NODE_ENV"] ?? "development";
if (!DATABASE_URL) {
throw new Error("DATABASE_URL is required");
}
if (!JWT_SECRET) {
throw new Error("JWT_SECRET is required");
}
return { PORT, DATABASE_URL, JWT_SECRET, NODE_ENV };
}
export const env = loadEnv();
Use it in your code:
import { env } from "./env.js";
app.listen(env.PORT, () => {
console.log(`Running in ${env.NODE_ENV} mode on port ${env.PORT}`);
});
This approach catches missing environment variables at startup instead of at runtime.
Project Structure
Here is a clean folder structure for a Node.js + TypeScript project:
src/
index.ts # Entry point — creates and starts the server
env.ts # Environment variable validation
types.ts # Shared type definitions
store.ts # Data storage layer
routes/
notes.ts # Note route handlers
users.ts # User route handlers
middleware/
auth.ts # Authentication middleware
logger.ts # Request logging
error.ts # Error handling
Building for Production
# Compile TypeScript to JavaScript
npx tsc
# Run the compiled code
node dist/index.js
The dist/ folder contains plain JavaScript that Node.js can run without any TypeScript tooling.
For production, you might also want:
rimraf dist && tsc— clean build- Docker with multi-stage build
- Process manager like PM2
What’s Next?
In the next tutorial, we will learn about TypeScript with Next.js — how to build full-stack applications with the App Router, Server Components, and Server Actions.