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:#fff

Artikel 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 Request di express.d.ts — tambahkan properti seperti req.pengguna dan req.requestId ke 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 middlewarevalidasiBody(schema) memastikan handler hanya menerima data yang sudah tervalidasi dan di-coerce; tanpanya, req.body bertipe any yang 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 meskipun next tidak digunakan.
  • Graceful shutdown — tangani SIGTERM dan SIGINT untuk 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, dan X-XSS-Protection untuk semua response; pertimbangkan helmet untuk 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.

← Sebelumnya: Web Socket   Berikutnya: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact