Web Server #
Building a web server with TypeScript isn’t just about adding type annotations to regular Express.js — TypeScript fundamentally changes the way you design and validate the request-response pipeline. With a strong type system, TypeScript lets you explicitly define the shape of request bodies, query parameters, path parameters, and responses; the compiler then maintains consistency across all layers — from router, middleware, controller, to service. This article covers how to build a production-ready TypeScript web server: with a structured architecture, strong request validation, consistent error handling, and correct graceful shutdown.
Framework Choices — A Brief Comparison #
flowchart TD
A[Web Server Framework\nTypeScript Choices] --> B[HTTP Module\nbuilt into Node.js]
A --> C[Express.js]
A --> D[Fastify]
A --> E[Hono]
A --> F[NestJS]
B --> B1[For: learning, simple proxies\nNot for: complex production]
C --> C1[For: flexible, huge ecosystem\nNot for: high performance]
D --> D1[For: performance, built-in schema validation\nTypeScript-first]
E --> E1[For: edge runtime, Cloudflare Workers\nUltra lightweight]
F --> F1[For: enterprise, DI, opinionated\nComplex but structured]
style C fill:#339af0,color:#fff
style D fill:#51cf66,color:#fff
style F fill:#cc5de8,color:#fffThis article focuses on Express.js because it has the largest ecosystem, but the patterns taught apply to any framework.
The Built-in HTTP Module — The Foundation #
Before frameworks, understand what’s underneath:
// src/server-native.ts — an HTTP server without a framework
import * as http from "http";
import { URL } from "url";
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const method = req.method ?? "GET";
// Manual routing — not scalable, only for illustration
if (method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", waktu: new Date().toISOString() }));
return;
}
if (method === "POST" && url.pathname === "/echo") {
let body = "";
req.on("data", (chunk) => { body += chunk.toString(); });
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(body);
});
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Endpoint tidak ditemukan" }));
});
server.listen(3000, () => {
console.log("Server berjalan di http://localhost:3000");
});
Express.js with TypeScript — Full Setup #
npm install express
npm install --save-dev @types/express @types/node typescript ts-node
Recommended Project Structure #
src/
├── index.ts # Entry point — initialization and listen
├── app.ts # Express app — middleware and routers
├── config/
│ └── env.ts # Type-safe environment variables
├── middleware/
│ ├── auth.ts # JWT middleware
│ ├── error.ts # Global error handler
│ ├── logger.ts # Request logger
│ └── validator.ts # Request validation with Zod
├── routes/
│ ├── index.ts # Aggregates all routers
│ ├── pengguna.ts # Router for the user resource
│ └── produk.ts # Router for the product resource
├── controllers/
│ ├── pengguna.ts # User request handlers
│ └── produk.ts # Product request handlers
├── services/
│ ├── pengguna.ts # User business logic
│ └── produk.ts # Product business logic
└── types/
└── express.d.ts # Express type augmentation
Express Type Augmentation #
One of the most important techniques: adding custom properties to Request so authentication data is available with type safety in all handlers:
// src/types/express.d.ts
// Augmentation — add properties to the Express Request interface
declare global {
namespace Express {
interface Request {
pengguna?: {
id: string;
email: string;
peran: "pengguna" | "admin" | "moderator";
};
requestId: string;
}
}
}
export {}; // Make sure this file is treated as a module
Type-safe Environment Variables #
// src/config/env.ts
import { z } from "zod"; // npm install zod
// Environment variable validation schema
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url("DATABASE_URL harus berupa URL yang valid"),
JWT_SECRET: z.string().min(32, "JWT_SECRET harus minimal 32 karakter"),
JWT_EXPIRES_IN: z.string().default("7d"),
ALLOWED_ORIGINS: z.string().transform((s) => s.split(",").map((o) => o.trim())),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
// Parse and validate at startup — fail fast if anything is missing
const hasilParsing = EnvSchema.safeParse(process.env);
if (!hasilParsing.success) {
console.error("❌ Environment variables tidak valid:");
console.error(hasilParsing.error.flatten().fieldErrors);
process.exit(1);
}
export const env = hasilParsing.data;
// env.PORT → number (not string!)
// env.DATABASE_URL → a string already validated as a URL
// env.ALLOWED_ORIGINS → string[] (already split)
Middleware — Correct Typing #
Request Logger #
// src/middleware/logger.ts
import { Request, Response, NextFunction } from "express";
import crypto from "crypto";
export function loggerMiddleware(req: Request, res: Response, next: NextFunction): void {
// Add a request ID for tracing
req.requestId = crypto.randomUUID();
const mulai = Date.now();
res.on("finish", () => {
const durasi = Date.now() - mulai;
console.log(JSON.stringify({
requestId: req.requestId,
method: req.method,
path: req.path,
status: res.statusCode,
durasi: `${durasi}ms`,
ip: req.ip,
userAgent: req.get("user-agent"),
}));
});
next();
}
JWT Authentication Middleware #
// src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { env } from "../config/env";
// Custom error type for authentication
class ErrorAuth extends Error {
constructor(
public readonly pesan: string,
public readonly statusCode = 401
) {
super(pesan);
this.name = "ErrorAuth";
}
}
export function autentikasiWajib(req: Request, res: Response, next: NextFunction): void {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
throw new ErrorAuth("Token autentikasi diperlukan");
}
const token = authHeader.slice(7);
const payload = verifikasiJWT(token, env.JWT_SECRET);
// The user type is already defined in express.d.ts
req.pengguna = {
id: payload.sub,
email: payload.email,
peran: payload.peran,
};
next();
} catch (err) {
if (err instanceof ErrorAuth) {
res.status(err.statusCode).json({ error: err.pesan });
} else {
res.status(401).json({ error: "Token tidak valid atau kedaluwarsa" });
}
}
}
export function hanyaAdmin(req: Request, res: Response, next: NextFunction): void {
if (req.pengguna?.peran !== "admin") {
res.status(403).json({ error: "Akses ditolak: diperlukan role admin" });
return;
}
next();
}
// Mock JWT verifier
function verifikasiJWT(token: string, _secret: string): { sub: string; email: string; peran: "pengguna" | "admin" | "moderator" } {
if (!token) throw new Error("Token kosong");
return { sub: "usr-001", email: "[email protected]", peran: "pengguna" };
}
Request Body Validation with Zod #
// src/middleware/validator.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import { z, ZodSchema } from "zod";
// Higher-order middleware: wrap a Zod schema into Express middleware
export function validasiBody<T>(schema: ZodSchema<T>): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
const hasil = schema.safeParse(req.body);
if (!hasil.success) {
res.status(400).json({
error: "Data request tidak valid",
detail: hasil.error.flatten().fieldErrors,
});
return;
}
// Replace the body with validated and coerced data
req.body = hasil.data;
next();
};
}
export function validasiQuery<T>(schema: ZodSchema<T>): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
const hasil = schema.safeParse(req.query);
if (!hasil.success) {
res.status(400).json({
error: "Query parameter tidak valid",
detail: hasil.error.flatten().fieldErrors,
});
return;
}
req.query = hasil.data as typeof req.query;
next();
};
}
Routers and Controllers — A Layered Architecture #
Validation Schemas #
// src/routes/pengguna.ts
import { Router } from "express";
import { z } from "zod";
import { validasiBody, validasiQuery } from "../middleware/validator";
import { autentikasiWajib, hanyaAdmin } from "../middleware/auth";
import * as PenggunaController from "../controllers/pengguna";
const router = Router();
// Zod schemas for validation
const SchemaBuatPengguna = z.object({
nama: z.string().min(2, "Nama minimal 2 karakter").max(100),
email: z.string().email("Format email tidak valid"),
password: z
.string()
.min(8, "Password minimal 8 karakter")
.regex(/[A-Z]/, "Password harus mengandung huruf besar")
.regex(/\d/, "Password harus mengandung angka"),
peran: z.enum(["pengguna", "moderator"]).default("pengguna"),
});
const SchemaQueryPengguna = z.object({
halaman: z.coerce.number().min(1).default(1),
perHalaman: z.coerce.number().min(1).max(100).default(20),
cari: z.string().optional(),
peran: z.enum(["pengguna", "moderator", "admin"]).optional(),
});
// Route definitions
router.get(
"/",
autentikasiWajib,
hanyaAdmin,
validasiQuery(SchemaQueryPengguna),
PenggunaController.daftarPengguna
);
router.post(
"/",
validasiBody(SchemaBuatPengguna),
PenggunaController.buatPengguna
);
router.get("/:id", autentikasiWajib, PenggunaController.ambilPengguna);
router.put(
"/:id",
autentikasiWajib,
validasiBody(SchemaBuatPengguna.partial()),
PenggunaController.perbaruiPengguna
);
router.delete("/:id", autentikasiWajib, hanyaAdmin, PenggunaController.hapusPengguna);
export default router;
Controller #
// src/controllers/pengguna.ts
import { Request, Response, NextFunction } from "express";
import * as LayananPengguna from "../services/pengguna";
// Controller: only handles HTTP — no business logic here
export async function daftarPengguna(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { halaman, perHalaman, cari, peran } = req.query as {
halaman: number;
perHalaman: number;
cari?: string;
peran?: string;
};
const hasil = await LayananPengguna.cariPengguna({ halaman, perHalaman, cari, peran });
res.json({
data: hasil.pengguna,
meta: {
total: hasil.total,
halaman,
perHalaman,
totalHalaman: Math.ceil(hasil.total / perHalaman),
},
});
} catch (err) {
next(err); // Forward to the global error handler
}
}
export async function buatPengguna(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const pengguna = await LayananPengguna.daftarPengguna(req.body);
res.status(201).json({ data: pengguna });
} catch (err) {
next(err);
}
}
export async function ambilPengguna(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const pengguna = await LayananPengguna.ambilPenggunaById(req.params.id);
if (!pengguna) {
res.status(404).json({ error: "Pengguna tidak ditemukan" });
return;
}
res.json({ data: pengguna });
} catch (err) {
next(err);
}
}
export async function perbaruiPengguna(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
// Make sure users can only update their own data (except admins)
if (req.pengguna?.peran !== "admin" && req.pengguna?.id !== req.params.id) {
res.status(403).json({ error: "Tidak bisa mengubah data pengguna lain" });
return;
}
const pengguna = await LayananPengguna.perbaruiPengguna(req.params.id, req.body);
res.json({ data: pengguna });
} catch (err) {
next(err);
}
}
export async function hapusPengguna(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
await LayananPengguna.hapusPengguna(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
Global Error Handler #
// src/middleware/error.ts
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
// Custom error base class
export class AppError extends Error {
constructor(
public readonly pesan: string,
public readonly statusCode: number = 500,
public readonly kode: string = "INTERNAL_ERROR"
) {
super(pesan);
this.name = "AppError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class ErrorTidakDitemukan extends AppError {
constructor(resource: string, id?: string) {
super(
id ? `${resource} dengan ID '${id}' tidak ditemukan` : `${resource} tidak ditemukan`,
404,
"NOT_FOUND"
);
}
}
export class ErrorValidasi extends AppError {
constructor(pesan: string) {
super(pesan, 400, "VALIDATION_ERROR");
}
}
// Global error handler — MUST have 4 parameters for Express to recognize it as an error handler
export function globalErrorHandler(
err: unknown,
req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction
): void {
// Log the error with request context
console.error({
requestId: req.requestId,
method: req.method,
path: req.path,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
// Handle by error type
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.pesan,
kode: err.kode,
requestId: req.requestId,
});
return;
}
if (err instanceof ZodError) {
res.status(400).json({
error: "Data tidak valid",
kode: "VALIDATION_ERROR",
detail: err.flatten().fieldErrors,
requestId: req.requestId,
});
return;
}
// Unknown errors — don't leak details to the client in production
res.status(500).json({
error: process.env.NODE_ENV === "production"
? "Terjadi kesalahan internal server"
: (err instanceof Error ? err.message : String(err)),
kode: "INTERNAL_ERROR",
requestId: req.requestId,
});
}
App and Entry Point #
// src/app.ts
import express from "express";
import { loggerMiddleware } from "./middleware/logger";
import { globalErrorHandler } from "./middleware/error";
import penggunaRouter from "./routes/pengguna";
import { env } from "./config/env";
export function buatApp() {
const app = express();
// Global middleware
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));
app.use(loggerMiddleware);
// CORS
app.use((req, res, next) => {
const origin = req.headers.origin ?? "";
if (env.ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
if (req.method === "OPTIONS") { res.sendStatus(204); return; }
next();
});
// Security headers
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
next();
});
// Health check — no authentication needed
app.get("/health", (_req, res) => {
res.json({ status: "ok", env: env.NODE_ENV, waktu: new Date().toISOString() });
});
// Routes
app.use("/api/pengguna", penggunaRouter);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: "Endpoint tidak ditemukan" });
});
// Global error handler — must be last
app.use(globalErrorHandler);
return app;
}
// src/index.ts — Entry point with graceful shutdown
import { buatApp } from "./app";
import { env } from "./config/env";
const app = buatApp();
const server = app.listen(env.PORT, () => {
console.log(`✅ Server berjalan di http://localhost:${env.PORT} [${env.NODE_ENV}]`);
});
// Graceful shutdown — finish in-flight requests before stopping
function gracefulShutdown(sinyal: string): void {
console.log(`\n⚠️ Menerima ${sinyal}, memulai graceful shutdown...`);
server.close((err) => {
if (err) {
console.error("Error saat menutup server:", err);
process.exit(1);
}
console.log("✅ Server berhasil ditutup");
process.exit(0);
});
// Force shutdown if not finished within 30 seconds
setTimeout(() => {
console.error("❌ Graceful shutdown timeout, force exit");
process.exit(1);
}, 30_000);
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); // Kubernetes pod stop
process.on("SIGINT", () => gracefulShutdown("SIGINT")); // Ctrl+C
process.on("unhandledRejection", (reason) => {
console.error("Unhandled Promise Rejection:", reason);
gracefulShutdown("unhandledRejection");
});
Summary #
- Augment
Requestinexpress.d.ts— add properties likereq.penggunaandreq.requestIdto the Express interface so they’re available with type safety in all handlers without manual casting.- Validate environment variables at startup with Zod — use
z.coerce.number()for PORT (string → number automatically) and fail fast with clear messages if any variable is missing; this prevents bugs that surface mid-runtime.- Validate request bodies with Zod as middleware —
validasiBody(schema)ensures handlers only receive validated and coerced data; without it,req.bodyis typedany, disabling all type checking.- A layered architecture (Router → Controller → Service) — routers only define routes and middleware, controllers only handle HTTP (parse requests, format responses), services only handle business logic; this separation makes testing and code evolution easier.
- A global error handler with 4 parameters is mandatory — Express recognizes error handlers by parameter count; the
(err, req, res, next)signature must not be shortened to 3 parameters even ifnextis unused.- Graceful shutdown — handle
SIGTERMandSIGINTto wait for in-flight requests to finish before the process stops; this prevents requests from being cut off mid-way during new deployments.- Minimal security headers — always set
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, andX-XSS-Protectionon all responses; considerhelmetfor a more complete set of security headers.- Don’t leak stack traces in production — in the global error handler, send a generic message to the client when
NODE_ENV === "production"and log full details on the server; leaked stack traces give attackers valuable information.