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.