kodingup.camp

Beranda/Node.js Backend: Dasar, Express & REST API

Validasi & Error Handling

Modul 8 dari 105 menit bacaValidation and Error Handling

Isi modul

    Kenapa ini penting

    Aturan nomor satu backend: jangan pernah percaya input. Data dari client bisa kosong, bertipe salah, terlalu panjang, berisi field tambahan yang tidak kamu minta (misalnya "admin": true), atau sengaja dirancang untuk merusak. Tanpa validasi, data sampah masuk ke database dan bug muncul berminggu-minggu kemudian di tempat yang tidak terduga.

    Pasangannya adalah error handling. Error pasti terjadi: file tidak ada, API luar mati, data tidak ditemukan. Yang membedakan API yang baik adalah bagaimana error itu dilaporkan: konsisten, informatif untuk client, aman (tidak membocorkan detail internal), dan tercatat di log untuk developer.

    Konsep inti

    Zod: skema sebagai satpam

    Zod adalah library validasi berbasis skema. Kamu mendeskripsikan bentuk data yang diizinkan, lalu Zod memeriksa data masuk. Analoginya satpam dengan daftar tamu: yang sesuai daftar boleh masuk, yang tidak sesuai ditolak dengan alasan jelas. Versi terbaru adalah Zod 4 (4.6.7 per 24 Oktober 2026). Pasang dengan npm install zod.

    import { z } from "zod";
    const Tugas = z.object({
      judul: z.string().trim().min(3, "Judul minimal 3 karakter"),
      prioritas: z.enum(["rendah", "sedang", "tinggi"]).default("sedang"),
    });
    

    Dua cara menjalankan:

    Hasil data adalah data yang sudah dibersihkan: string di-trim, default diisi, dan field yang tidak ada di skema dibuang (perilaku default z.object). Kalau kamu ingin field asing ditolak, pakai z.strictObject({...}).

    Pesan kustom ditulis sebagai argumen string (.min(3, "Judul minimal 3 karakter")) atau objek { error: "..." }. Untuk diubah ke format yang enak dikirim, z.flattenError(error) menghasilkan { formErrors: [...], fieldErrors: { field: [...] } }. formErrors berisi error tingkat atas (misalnya body bukan objek), fieldErrors berisi error per field. Ada juga z.treeifyError untuk struktur bersarang.

    Fitur yang sering dipakai di API:

    Fitur Contoh Fungsi
    z.coerce.number() query "12" → 12 Mengubah string jadi angka (penting untuk query/params)
    z.enum([...]) "tinggi" Hanya nilai tertentu
    z.uuid() id Format UUID
    .optional() / .default(v) Boleh kosong / isi otomatis
    .partial() skema update Semua field jadi opsional
    .refine(fn, pesan) Aturan kustom

    Untuk TypeScript, type Tugas = z.infer<typeof Tugas> menghasilkan tipe dari skema, jadi validasi dan tipe tidak pernah berbeda.

    Middleware validasi

    Daripada menulis safeParse di setiap handler, buat satu middleware pabrik validate(schema, "body" | "params" | "query"). Hasil yang sudah bersih disimpan di req.valid, karena di Express 5 req.query hanya getter dan tidak bisa ditimpa.

    Error handling di Express 5

    Error handler adalah middleware dengan empat parameter: (err, req, res, next). Express mengenalinya dari jumlah parameter, jadi keempatnya wajib ditulis walaupun next tidak dipakai. Pasang paling akhir.

    Kabar terbaik Express 5: error dari fungsi async otomatis diteruskan ke error handler. Kalau handler async melempar error atau await sebuah Promise yang gagal, Express memanggil next(err) untukmu. Di Express 4, error seperti itu tidak tertangkap dan request menggantung, sehingga orang harus membungkus setiap handler dengan try/catch atau paket express-async-errors. Di Express 5, cukup throw.

    Kelas error kustom

    Buat kelas AppError yang membawa status, code, message, dan details. Turunannya seperti NotFoundError dan ValidationError membuat kode handler sangat bersih: throw new NotFoundError("Tugas tidak ditemukan"). Error handler cukup memeriksa err instanceof AppError lalu memformatnya. Error lain yang tidak dikenali dianggap bug: catat lengkap di log, kirim 500 dengan pesan umum.

    Error dari express.json()

    JSON rusak menghasilkan error dengan err.type === "entity.parse.failed" dan err.status === 400. Tangani khusus supaya client mendapat JSON, bukan halaman HTML berisi stack trace seperti yang kita lihat di modul Dasar Express.

    404 handler

    404 untuk rute tidak dikenal bukan error handler; ia middleware biasa (3 parameter) yang dipasang setelah semua rute. Bedakan dengan 404 untuk data yang tidak ditemukan (NotFoundError) yang dilempar dari dalam handler.

    Contoh kode

    Error dari async otomatis tertangkap di Express 5:

    // async-error.js
    import express from "express";
    import { readFile } from "node:fs/promises";
    
    const app = express();
    
    app.get("/config", async (req, res) => {
      const isi = await readFile("config-tidak-ada.json", "utf8"); // reject → ENOENT
      res.json(JSON.parse(isi));
    });
    
    app.get("/sinkron", (req, res) => {
      throw new Error("Error sinkron juga tertangkap");
    });
    
    app.use((err, req, res, next) => {
      console.error("Tertangkap:", err.code ?? err.message);
      res.status(500).json({ error: err.message });
    });
    
    app.listen(process.env.PORT ?? 3000);
    
    // curl -s -w ' [%{http_code}]' localhost:3000/config
    // → {"error":"ENOENT: no such file or directory, open 'config-tidak-ada.json'"} [500]
    // curl -s -w ' [%{http_code}]' localhost:3000/sinkron
    // → {"error":"Error sinkron juga tertangkap"} [500]
    // Log server:
    // → Tertangkap: ENOENT
    // → Tertangkap: Error sinkron juga tertangkap
    

    (Contoh ini sengaja mengirim err.message supaya terlihat; di produksi jangan kirim pesan error mentah ke client.)

    Versi rapi yang dipakai proyek Tugas API:

    // src/errors.js
    export class AppError extends Error {
      constructor(status, code, message, details) {
        super(message);
        this.name = "AppError";
        this.status = status;
        this.code = code;
        this.details = details;
      }
    }
    
    export class NotFoundError extends AppError {
      constructor(message = "Data tidak ditemukan") {
        super(404, "NOT_FOUND", message);
      }
    }
    
    export class ValidationError extends AppError {
      constructor(details) {
        super(422, "VALIDATION_ERROR", "Data yang dikirim tidak valid", details);
      }
    }
    
    // src/schemas.js
    import { z } from "zod";
    
    // Field dasar TANPA default, dipakai bersama oleh create & update
    const judul = z.string().trim().min(3, "Judul minimal 3 karakter").max(100, "Judul maksimal 100 karakter");
    const prioritas = z.enum(["rendah", "sedang", "tinggi"]);
    const selesai = z.boolean();
    
    export const tugasBaruSchema = z.object({
      judul,
      prioritas: prioritas.default("sedang"),
      selesai: selesai.default(false),
    });
    
    // PATCH: semua opsional, TANPA default (lihat Kesalahan umum no. 1)
    export const tugasUpdateSchema = z
      .object({ judul, prioritas, selesai })
      .partial()
      .refine((data) => Object.keys(data).length > 0, "Kirim minimal satu field untuk diubah");
    
    export const idParamSchema = z.object({
      id: z.uuid("Format id harus UUID"),
    });
    
    export const listQuerySchema = z.object({
      page: z.coerce.number().int().min(1).default(1),
      limit: z.coerce.number().int().min(1).max(50).default(10),
      selesai: z.enum(["true", "false"]).optional(),
      q: z.string().trim().optional(),
      sort: z.enum(["dibuatPada", "-dibuatPada", "judul", "-judul"]).default("-dibuatPada"),
    });
    
    // src/validate.js
    import { z } from "zod";
    import { ValidationError } from "./errors.js";
    
    // Pembuat middleware: validate(schema, "body" | "params" | "query")
    export function validate(schema, sumber = "body") {
      return (req, res, next) => {
        const hasil = schema.safeParse(req[sumber]);
        if (!hasil.success) {
          // flattenError → { formErrors: [...], fieldErrors: { field: [...] } }
          throw new ValidationError(z.flattenError(hasil.error));
        }
        // req.query di Express 5 hanya getter (tidak bisa ditimpa), jadi simpan hasil di req.valid
        req.valid ??= {};
        req.valid[sumber] = hasil.data;
        next();
      };
    }
    
    // Bagian akhir src/app.js: 404 lalu error handler
    import { AppError } from "./errors.js";
    
    app.use((req, res) => {
      res.status(404).json({
        error: { code: "ROUTE_NOT_FOUND", message: `Rute ${req.method} ${req.originalUrl} tidak ada` },
      });
    });
    
    // Error handler: WAJIB 4 parameter
    app.use((err, req, res, next) => {
      if (err instanceof AppError) {
        return res.status(err.status).json({
          error: { code: err.code, message: err.message, details: err.details },
        });
      }
      if (err.type === "entity.parse.failed") {
        return res.status(400).json({ error: { code: "INVALID_JSON", message: "Body bukan JSON yang valid" } });
      }
      console.error(err); // bug sungguhan: catat lengkap di server
      res.status(500).json({ error: { code: "INTERNAL", message: "Terjadi kesalahan di server" } });
    });
    

    Output asli dari server Tugas API (lengkapnya di modul berikutnya):

    curl -s -w ' [%{http_code}]' -X POST localhost:3000/api/v1/tugas \
      -H 'Content-Type: application/json' -d '{"judul":"ab","prioritas":"darurat"}'
    # → {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid","details":{"formErrors":[],"fieldErrors":{"judul":["Judul minimal 3 karakter"],"prioritas":["Invalid option: expected one of \"rendah\"|\"sedang\"|\"tinggi\""]}}}} [422]
    
    curl -s -w ' [%{http_code}]' -X POST localhost:3000/api/v1/tugas
    # → {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid","details":{"formErrors":["Invalid input: expected object, received undefined"],"fieldErrors":{}}}} [422]
    
    curl -s -w ' [%{http_code}]' -X POST localhost:3000/api/v1/tugas \
      -H 'Content-Type: application/json' -d '{"judul":'
    # → {"error":{"code":"INVALID_JSON","message":"Body bukan JSON yang valid"}} [400]
    
    curl -s -w ' [%{http_code}]' "localhost:3000/api/v1/tugas?limit=500&sort=harga"
    # → {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid","details":{"formErrors":[],"fieldErrors":{"limit":["Too big: expected number to be <=50"],"sort":["Invalid option: expected one of \"dibuatPada\"|\"-dibuatPada\"|\"judul\"|\"-judul\""]}}}} [422]
    
    curl -s -w ' [%{http_code}]' localhost:3000/api/v1/tugas/123
    # → {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid","details":{"formErrors":[],"fieldErrors":{"id":["Format id harus UUID"]}}}} [422]
    
    curl -s -w ' [%{http_code}]' localhost:3000/api/v1/pengguna
    # → {"error":{"code":"ROUTE_NOT_FOUND","message":"Rute GET /api/v1/pengguna tidak ada"}} [404]
    

    Perhatikan pesan bawaan Zod berbahasa Inggris (Invalid option..., Too big...). Kamu bisa menggantinya per field dengan argumen pesan, atau secara global dengan konfigurasi locale Zod.

    Kesalahan umum

    1. Memakai .default() di skema lalu .partial() untuk PATCH. Ini jebakan nyata yang kami temui saat menguji: default tetap diterapkan, sehingga PATCH {"selesai": true} diam-diam mengembalikan prioritas ke "sedang". Diuji langsung:

    js const salah = z.object({ prioritas: z.enum(["rendah", "sedang", "tinggi"]).default("sedang"), selesai: z.boolean().default(false), }).partial(); console.log(salah.parse({ selesai: true })); // → { prioritas: 'sedang', selesai: true } ← prioritas ikut "diubah"!

    Solusinya: skema update dibangun dari field tanpa default, seperti di schemas.js di atas. 2. Error handler dengan 3 parameter. Express menganggapnya middleware biasa dan tidak pernah memanggilnya untuk error. Tulis (err, req, res, next) lengkap. 3. Memakai req.body mentah setelah validasi. Data mentah belum di-trim, belum ada default, dan bisa berisi field asing. Selalu pakai hasil safeParse (req.valid.body). 4. Lupa z.coerce untuk query dan params. Semuanya string, jadi z.number() akan selalu gagal untuk ?page=2. 5. Mengirim err.message atau stack trace untuk error 500. Bisa membocorkan path file atau query database. Kirim pesan umum, catat detailnya di log.

    Latihan

    1. Mudah, Buat skema daftarSchema untuk pendaftaran: email (format email, pakai z.email()), nama (2 sampai 50 karakter), umur (bilangan bulat minimal 13, dari string boleh dikonversi). Uji dengan safeParse untuk satu data valid dan satu tidak valid.
    2. Sedang, Tambahkan kelas ConflictError (409, code "CONFLICT"), lalu lempar dari rute POST /pengguna kalau email sudah ada di array.
    3. Menantang, Ubah skema pendaftaran agar menolak field asing (misalnya "admin": true) dan menambahkan konfirmasi password: field password minimal 8 karakter dan konfirmasi harus sama. Error ketidakcocokan harus muncul di field konfirmasi.
    Lihat solusi

    1.

    import { z } from "zod";
    
    const daftarSchema = z.object({
      email: z.email("Format email salah"),
      nama: z.string().trim().min(2).max(50),
      umur: z.coerce.number().int().min(13, "Minimal 13 tahun"),
    });
    
    console.log(daftarSchema.safeParse({ email: "sari@contoh.id", nama: "Sari", umur: "17" }));
    // → { success: true, data: { email: 'sari@contoh.id', nama: 'Sari', umur: 17 } }   (umur jadi number)
    
    const gagal = daftarSchema.safeParse({ email: "bukan-email", nama: "S", umur: 10 });
    console.log(z.flattenError(gagal.error).fieldErrors);
    // → {
    // →   email: [ 'Format email salah' ],
    // →   nama: [ 'Too small: expected string to have >=2 characters' ],
    // →   umur: [ 'Minimal 13 tahun' ]
    // → }
    

    2.

    // errors.js
    export class ConflictError extends AppError {
      constructor(message = "Data sudah ada") {
        super(409, "CONFLICT", message);
      }
    }
    
    // rute
    const pengguna = [];
    app.post("/pengguna", validate(daftarSchema), (req, res) => {
      const { email } = req.valid.body;
      if (pengguna.some((p) => p.email === email)) throw new ConflictError("Email sudah terdaftar");
      pengguna.push(req.valid.body);
      res.status(201).json({ data: req.valid.body });
    });
    

    3.

    const daftarKetat = z
      .strictObject({
        email: z.email(),
        password: z.string().min(8, "Password minimal 8 karakter"),
        konfirmasi: z.string(),
      })
      .refine((d) => d.password === d.konfirmasi, {
        message: "Konfirmasi password tidak sama",
        path: ["konfirmasi"],
      });
    
    const h = daftarKetat.safeParse({ email: "a@b.id", password: "rahasia123", konfirmasi: "beda", admin: true });
    console.log(z.flattenError(h.error));
    // → {
    // →   formErrors: [ 'Unrecognized key: "admin"' ],
    // →   fieldErrors: { konfirmasi: [ 'Konfirmasi password tidak sama' ] }
    // → }
    

    Kedua masalah dilaporkan sekaligus: field asing admin masuk ke formErrors (error tingkat objek), sedangkan ketidakcocokan password masuk ke fieldErrors.konfirmasi berkat opsi path. Tanpa path, error refine akan masuk ke formErrors.

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Type-Safe Express APIs with Zod v4: One Schema, No Duplicate TypesAlex Rusin · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.