Web Server #
Membangun web server dengan TypeScript bukan sekadar menambahkan anotasi tipe ke Express.js biasa — TypeScript mengubah cara kamu merancang dan memvalidasi request-response pipeline secara fundamental. Dengan sistem tipe yang kuat, TypeScript memungkinkan kamu mendefinisikan secara eksplisit bentuk request body, query parameter, path parameter, dan response; compiler kemudian menjaga konsistensi di seluruh layer — dari router, middleware, controller, hingga service. Artikel ini membahas cara membangun web server TypeScript yang production-ready: dengan arsitektur yang terstruktur, validasi request yang kuat, penanganan error yang konsisten, dan graceful shutdown yang benar.
Pilihan Framework — Perbandingan Singkat #
flowchart TD
A[Pilihan Framework\nWeb Server TypeScript] --> B[HTTP Module\nbawaan Node.js]
A --> C[Express.js]
A --> D[Fastify]
A --> E[Hono]
A --> F[NestJS]
B --> B1[Untuk: belajar, proxy sederhana\nTidak untuk: produksi kompleks]
C --> C1[Untuk: fleksibel, ekosistem besar\nTidak untuk: performa tinggi]
D --> D1[Untuk: performa, schema validation bawaan\nTypeScript-first]
E --> E1[Untuk: edge runtime, Cloudflare Workers\nUltra lightweight]
F --> F1[Untuk: enterprise, DI, opinionated\nKompleks tapi terstruktur]
style C fill:#339af0,color:#fff
style D fill:#51cf66,color:#fff
style F fill:#cc5de8,color:#fffArtikel ini fokus pada Express.js karena ekosistemnya paling besar, tapi pola yang diajarkan berlaku untuk framework manapun.
HTTP Module Bawaan — Fondasi #
Sebelum framework, pahami apa yang ada di bawahnya:
// src/server-native.ts — HTTP server tanpa 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";
// Routing manual — tidak scalable, hanya untuk ilustrasi
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 dengan TypeScript — Setup Lengkap #
npm install express
npm install --save-dev @types/express @types/node typescript ts-node
Struktur Proyek yang Direkomendasikan #
src/
├── index.ts # Entry point — inisialisasi dan listen
├── app.ts # Express app — middleware dan router
├── config/
│ └── env.ts # Environment variables yang type-safe
├── middleware/
│ ├── auth.ts # JWT middleware
│ ├── error.ts # Global error handler
│ ├── logger.ts # Request logger
│ └── validator.ts # Request validation dengan Zod
├── routes/
│ ├── index.ts # Agregasi semua router
│ ├── pengguna.ts # Router untuk resource pengguna
│ └── produk.ts # Router untuk resource produk
├── controllers/
│ ├── pengguna.ts # Handler request pengguna
│ └── produk.ts # Handler request produk
├── services/
│ ├── pengguna.ts # Business logic pengguna
│ └── produk.ts # Business logic produk
└── types/
└── express.d.ts # Augmentasi tipe Express
Augmentasi Tipe Express #
Salah satu teknik paling penting: menambahkan properti kustom ke Request agar data autentikasi tersedia dengan type safety di semua handler:
// src/types/express.d.ts
// Augmentasi — tambahkan properti ke interface Request Express
declare global {
namespace Express {
interface Request {
pengguna?: {
id: string;
email: string;
peran: "pengguna" | "admin" | "moderator";
};
requestId: string;
}
}
}
export {}; // Pastikan file ini diperlakukan sebagai modul
Environment Variables yang Type-safe #
// src/config/env.ts
import { z } from "zod"; // npm install zod
// Schema validasi environment variables
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 dan validasi saat startup — gagal cepat jika ada yang kurang
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 (bukan string!)
// env.DATABASE_URL → string yang sudah divalidasi sebagai URL
// env.ALLOWED_ORIGINS → string[] (sudah di-split)
Middleware — Typing yang Benar #
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 {
// Tambahkan request ID untuk 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";
// Tipe custom error untuk autentikasi
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);
// Tipe pengguna sudah didefinisikan di 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" };
}
Validasi Request Body dengan Zod #
// src/middleware/validator.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import { z, ZodSchema } from "zod";
// Higher-order middleware: bungkus schema Zod menjadi middleware Express
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;
}
// Ganti body dengan data yang sudah divalidasi dan di-coerce
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();
};
}
Router dan Controller — Arsitektur Berlapis #
Schema Validasi #
// 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();
// Schema Zod untuk validasi
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: hanya tangani HTTP — tidak ada business logic di sini
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); // Teruskan ke 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 {
// Pastikan pengguna hanya bisa update data sendiri (kecuali admin)
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 — HARUS 4 parameter agar Express mengenalinya sebagai error handler
export function globalErrorHandler(
err: unknown,
req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction
): void {
// Log error dengan 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,
});
// Tangani berdasarkan jenis error
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;
}
// Error tidak dikenal — jangan bocorkan detail ke client di produksi
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 dan 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();
// Middleware global
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 — tidak perlu autentikasi
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 — harus di akhir
app.use(globalErrorHandler);
return app;
}
// src/index.ts — Entry point dengan 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 — selesaikan request yang sedang berjalan sebelum berhenti
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 jika tidak selesai dalam 30 detik
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");
});
Ringkasan #
- Augmentasi
Requestdiexpress.d.ts— tambahkan properti sepertireq.penggunadanreq.requestIdke interface Express agar tersedia dengan type safety di semua handler tanpa casting manual.- Validasi environment variables di startup dengan Zod — gunakan
z.coerce.number()untuk PORT (string → number otomatis) dan gagal cepat dengan pesan yang jelas jika ada variabel yang kurang; ini mencegah bug yang muncul di tengah runtime.- Validasi request body dengan Zod sebagai middleware —
validasiBody(schema)memastikan handler hanya menerima data yang sudah tervalidasi dan di-coerce; tanpanya,req.bodybertipeanyyang menonaktifkan semua type checking.- Arsitektur berlapis (Router → Controller → Service) — router hanya mendefinisikan route dan middleware, controller hanya menangani HTTP (parse request, format response), service hanya menangani business logic; pemisahan ini memudahkan testing dan evolusi kode.
- Global error handler dengan 4 parameter wajib — Express mengenali error handler dari jumlah parameter; signature
(err, req, res, next)tidak boleh disingkat menjadi 3 parameter meskipunnexttidak digunakan.- Graceful shutdown — tangani
SIGTERMdanSIGINTuntuk menunggu request yang sedang berjalan selesai sebelum proses berhenti; ini mencegah request yang terputus di tengah jalan saat deployment baru dilakukan.- Security headers minimal — selalu set
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, danX-XSS-Protectionuntuk semua response; pertimbangkanhelmetuntuk set header keamanan yang lebih lengkap.- Jangan bocorkan stack trace di produksi — di global error handler, kirim pesan generik ke client jika
NODE_ENV === "production", log detail lengkap di server; stack trace yang bocor memberi informasi berharga untuk penyerang.