Zod Lanjutan: Fitur Advanced dan Arsitektural (Bagian 2)
13 min read

Zod Lanjutan: Fitur Advanced dan Arsitektural (Bagian 2)

Artikel ini melanjutkan pembahasan Zod dari fondasi dasar ke wilayah yang lebih dalam. Jika bagian pertama membahas validasi yang digunakan sehari-hari — string, number, object, array, union — bagian ini fokus pada fitur yang baru relevan ketika kamu membangun sistem yang lebih kompleks: schema rekursif untuk struktur tree, pipeline transform berlapis, error map global untuk i18n, hingga boundary layer pattern yang membuat sistem menjadi fail-fast sejak titik masuknya data. Menguasai fitur-fitur ini yang membedakan penggunaan Zod sebagai form validator biasa dengan penggunaannya sebagai fondasi arsitektur yang serius.

Tipe Primitif yang Jarang Dipakai

Sebagian besar developer Zod tidak pernah menyentuh tipe-tipe ini, tapi ada situasi spesifik di mana masing-masing menjadi satu-satunya pilihan yang tepat.

BigInt

z.bigint();
z.bigint().positive();
z.bigint().min(BigInt(0));

BigInt dibutuhkan ketika bekerja dengan integer yang melampaui batas Number.MAX_SAFE_INTEGER (2^53 - 1). Kasus nyatanya antara lain sistem finansial yang menangani angka dalam satuan sen untuk menghindari floating point error, integrasi blockchain di mana ID transaksi atau block number bisa sangat besar, dan kriptografi yang melibatkan operasi modular aritmetik.

// ANTI-PATTERN: menggunakan z.number() untuk ID blockchain
const txSchema = z.object({
  blockNumber: z.number(), // overflow untuk block number yang besar
  transactionId: z.string(),
});

// BENAR: z.bigint() untuk nilai yang bisa melebihi Number.MAX_SAFE_INTEGER
const txSchema = z.object({
  blockNumber: z.bigint().positive(),
  transactionId: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
});

Symbol

z.symbol();

Jarang dipakai dalam aplikasi bisnis umum, tapi relevan ketika kamu membangun utility library yang perlu memvalidasi Symbol sebagai key unik atau identifier internal.

Undefined dan Null Eksplisit

z.undefined(); // field harus undefined
z.null();      // field harus null (bukan undefined)

Perbedaan antara undefined dan null sering diabaikan, padahal keduanya memiliki semantik berbeda di banyak API. Gunakan tipe eksplisit ini ketika membangun kontrak API yang sangat ketat — misalnya memastikan sebuah field dikirim sebagai null (penanda “sengaja dikosongkan”) versus tidak dikirim sama sekali (undefined).

// Kontrak API yang membedakan null vs undefined
const updateUserSchema = z.object({
  name: z.string().optional(),           // boleh tidak dikirim
  avatar: z.string().nullable(),         // boleh dikirim sebagai null (hapus avatar)
  deletedAt: z.null(),                   // harus eksplisit null
});

any, unknown, dan never

Ketiga tipe ini membentuk spektrum kontrol — dari yang paling longgar hingga paling ketat.

flowchart LR
    A["z.any()\nTidak ada validasi\nTidak ada type safety"] --> B["z.unknown()\nTidak ada validasi\nAda type safety"]
    B --> C["z.never()\nTidak boleh ada\nnilai sama sekali"]

z.any()

Menerima semua nilai tanpa validasi apapun dan menonaktifkan type checking di TypeScript. Ini setara dengan menulis as any — berbahaya karena membuka celah untuk data tak terduga masuk ke sistem.

// ANTI-PATTERN: z.any() di schema produksi
const schema = z.object({
  metadata: z.any(), // tidak ada jaminan bentuk data
});

// BENAR: gunakan z.unknown() lalu narrow, atau definisikan schema spesifik
const schema = z.object({
  metadata: z.unknown(), // TypeScript memaksa pengecekan sebelum digunakan
});

// atau lebih baik lagi, definisikan bentuknya
const schema = z.object({
  metadata: z.record(z.string(), z.unknown()),
});

z.unknown()

Lebih aman dari z.any() karena TypeScript tetap memaksa narrowing sebelum nilai bisa digunakan. Ini adalah pilihan yang tepat di boundary layer — ketika kamu menerima data dari luar (API eksternal, webhook, file upload) yang belum diketahui bentuknya secara pasti.

// Contoh: parsing response API eksternal yang tidak terprediksi
function parseExternalResponse(raw: unknown) {
  const schema = z.object({
    status: z.string(),
    data: z.unknown(), // kita tahu ada field data, tapi tidak tahu isinya
    timestamp: z.string().datetime(),
  });

  return schema.parse(raw);
}

z.never()

Memastikan suatu nilai tidak boleh ada sama sekali. Paling berguna dalam discriminated union untuk memastikan semua kasus sudah ditangani (exhaustive checking):

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      // z.never() memastikan TypeScript error jika ada kasus yang terlewat
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Tuple — Array dengan Struktur Tetap

z.array() memvalidasi array dengan elemen bertipe sama dan panjang bebas. z.tuple() memvalidasi array dengan tipe spesifik per posisi dan panjang tetap.

// z.array(): semua elemen bertipe sama, panjang bebas
const tags = z.array(z.string()); // ["tag1", "tag2", "tag3", ...]

// z.tuple(): tiap posisi punya tipe sendiri, panjang tetap
const coordinate = z.tuple([z.number(), z.number()]);          // [lat, lng]
const entry = z.tuple([z.string(), z.number(), z.boolean()]);  // ["key", 42, true]

Tuple berguna untuk merepresentasikan pasangan nilai yang punya urutan bermakna — koordinat geografis, nilai dengan unit, return value fungsi yang mengembalikan [error, result] ala Go:

// ANTI-PATTERN: menggunakan array biasa untuk data terstruktur
const resultSchema = z.array(z.unknown()); // tidak tahu posisi mana error, mana data

// BENAR: tuple dengan tipe eksplisit per posisi
const resultSchema = z.tuple([
  z.union([z.instanceof(Error), z.null()]), // posisi 0: error atau null
  z.unknown(),                               // posisi 1: data hasil
]);

// Bisa juga ditambah rest element untuk elemen tambahan opsional
const csvRow = z.tuple([z.string(), z.string()]).rest(z.string());

Set dan Map

Zod mendukung validasi struktur data ES6 secara native — berguna ketika layer domain model kamu memang menggunakan Set atau Map, bukan plain array atau object.

// Set: koleksi nilai unik tanpa duplikat
const tagSet = z.set(z.string());
// valid: new Set(["typescript", "zod"])
// tidak valid: "typescript" (bukan Set)

// Map: pasangan key-value dengan tipe eksplisit
const scoreMap = z.map(z.string(), z.number());
// valid: new Map([["alice", 95], ["bob", 87]])

// Bisa dikombinasikan dengan constraint
const uniqueEmails = z.set(z.string().email()).min(1).max(100);
// ANTI-PATTERN: mengkonversi ke array hanya untuk validasi
const schema = z.object({
  selectedIds: z.array(z.string()), // kehilangan semantik "unik"
});

// BENAR: validasi Set secara langsung jika domain memang butuh keunikan
const schema = z.object({
  selectedIds: z.set(z.string().uuid()),
});

default() vs catch()

Keduanya memberikan nilai fallback, tapi kondisi pemicunya berbeda — dan perbedaan ini sering menjadi sumber bug yang sulit ditemukan.

default()catch()
Kapan aktifNilai input adalah undefinedValidasi gagal (error)
Input nullTidak aktif (null ≠ undefined)Tidak aktif
Input tipe salahTidak aktifAktif
Use caseNilai opsional dengan default wajarGraceful degradation saat data rusak
// default(): hanya aktif jika undefined
const schema = z.object({
  role: z.enum(["admin", "user"]).default("user"),
  theme: z.string().default("light"),
});

schema.parse({});                     // { role: "user", theme: "light" }
schema.parse({ role: undefined });    // { role: "user", theme: "light" }
schema.parse({ role: null });         // ERROR — null bukan "admin" | "user"

// catch(): aktif saat validasi gagal
const safeNumber = z.number().catch(0);

safeNumber.parse(42);        // 42
safeNumber.parse("invalid"); // 0  ← validasi gagal, fallback ke 0
safeNumber.parse(undefined); // 0  ← undefined juga gagal validasi number
// ANTI-PATTERN: menggunakan catch() untuk semua fallback
const schema = z.object({
  username: z.string().catch(""), // data rusak dilempar, masalah tersembunyi
});

// BENAR: catch() hanya untuk nilai yang memang boleh degraded gracefully
// gunakan default() untuk field opsional yang punya nilai wajar
const configSchema = z.object({
  timeout: z.number().positive().default(5000),  // opsional, default 5 detik
  retryCount: z.number().catch(3),               // data konfigurasi rusak → fallback 3
});

pipe() — Transform Berlapis dengan Validasi

transform() mengubah nilai tapi hasilnya tidak divalidasi kembali. pipe() menyambungkan dua schema — output schema pertama menjadi input schema kedua — sehingga hasil transform tetap divalidasi.

// ANTI-PATTERN: transform() tanpa validasi hasil
const schema = z.string().transform((val) => parseInt(val));
// parse("abc") → NaN  ← lolos tanpa error!
// parse("-5")  → -5   ← lolos tanpa error!

// BENAR: pipe() memastikan hasil transform divalidasi ulang
const schema = z
  .string()
  .transform((val) => parseInt(val, 10))
  .pipe(z.number().int().positive());

schema.parse("42");   // 42
schema.parse("abc");  // ERROR — NaN gagal z.number()
schema.parse("-5");   // ERROR — -5 gagal .positive()

pipe() sangat berguna untuk konversi tipe dari input string (form, query param, env var) ke tipe yang lebih kuat:

// Parsing query parameter yang selalu masuk sebagai string
const paginationSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().int().min(1)).default("1"),
  limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).default("20"),
  sort: z.enum(["asc", "desc"]).default("desc"),
});

// parse(req.query) — semua nilai dari URL query string adalah string
const params = paginationSchema.parse(req.query);
// params.page  → number (bukan string)
// params.limit → number (bukan string)

describe() — Metadata Schema

const userSchema = z.object({
  email: z.string().email().describe("Alamat email untuk login dan notifikasi"),
  age: z.number().int().min(0).max(150).describe("Usia dalam tahun"),
  role: z.enum(["admin", "user", "guest"]).describe("Level akses pengguna"),
});

describe() menyematkan metadata string ke schema. Ini tidak mempengaruhi validasi sama sekali, tapi sangat berguna untuk tooling yang membaca schema Zod secara programatik — terutama generator OpenAPI/Swagger yang bisa mengambil deskripsi ini sebagai dokumentasi field secara otomatis.


Global Error Map — Standardisasi dan i18n

Secara default, pesan error Zod menggunakan Bahasa Inggris. Untuk aplikasi yang butuh pesan error dalam bahasa lain atau format yang konsisten di seluruh sistem, gunakan z.setErrorMap():

// ANTI-PATTERN: override pesan error di setiap schema secara manual
const schema = z.object({
  email: z.string({ required_error: "Email wajib diisi" }).email("Format email tidak valid"),
  password: z.string({ required_error: "Password wajib diisi" }).min(8, "Minimal 8 karakter"),
  // ... diulang di setiap schema
});

// BENAR: set global error map sekali, berlaku ke semua schema
import { z, ZodIssueCode } from "zod";

z.setErrorMap((issue, ctx) => {
  switch (issue.code) {
    case ZodIssueCode.too_small:
      if (issue.type === "string") {
        return { message: `Minimal ${issue.minimum} karakter` };
      }
      if (issue.type === "number") {
        return { message: `Nilai minimal adalah ${issue.minimum}` };
      }
      break;
    case ZodIssueCode.too_big:
      if (issue.type === "string") {
        return { message: `Maksimal ${issue.maximum} karakter` };
      }
      break;
    case ZodIssueCode.invalid_type:
      if (issue.received === "undefined") {
        return { message: "Field ini wajib diisi" };
      }
      return { message: `Tipe data tidak valid` };
    case ZodIssueCode.invalid_string:
      if (issue.validation === "email") {
        return { message: "Format email tidak valid" };
      }
      if (issue.validation === "url") {
        return { message: "Format URL tidak valid" };
      }
      break;
  }
  return { message: ctx.defaultError };
});

Panggil setErrorMap() satu kali saat aplikasi pertama kali diinisialisasi (misalnya di _app.tsx atau entry point server), dan semua schema Zod di seluruh codebase akan menggunakan pesan error yang sudah dikustomisasi.


Schema Rekursif dengan z.lazy()

Struktur data rekursif — pohon kategori, komentar bersarang, menu navigasi bertingkat, file system — tidak bisa didefinisikan dengan schema biasa karena TypeScript akan mengeluh tentang referensi siklik. z.lazy() menyelesaikan ini dengan menunda evaluasi schema hingga runtime.

// ANTI-PATTERN: referensi langsung menyebabkan TypeScript error
const Category = z.object({
  name: z.string(),
  children: z.array(Category), // Error: Block-scoped variable 'Category' used before its declaration
});

// BENAR: z.lazy() menunda evaluasi hingga runtime
type Category = {
  name: string;
  children: Category[];
};

const categorySchema: z.ZodType<Category> = z.object({
  name: z.string(),
  children: z.array(z.lazy(() => categorySchema)),
});

// Contoh data valid:
categorySchema.parse({
  name: "Electronics",
  children: [
    {
      name: "Phones",
      children: [
        { name: "Android", children: [] },
        { name: "iOS", children: [] },
      ],
    },
    { name: "Laptops", children: [] },
  ],
});
flowchart TD
    A["categorySchema\n{ name, children }"] --> B["z.lazy(() => categorySchema)"]
    B --> C["Evaluasi ditunda\nhingga runtime"]
    C --> A
    style C fill:#f9f,stroke:#333
Schema rekursif dengan z.lazy() tidak akan berhenti jika data yang di-parse memiliki siklus (node A merujuk ke node B yang merujuk kembali ke node A). Pastikan data yang masuk benar-benar berbentuk pohon, bukan graf dengan siklus, atau tambahkan batas kedalaman secara manual dengan superRefine.

Boundary Layer Pattern

Ini adalah pola arsitektural paling penting dari seluruh artikel. Ide dasarnya: validasi dengan Zod harus terjadi di batas sistem (boundary) — yaitu di titik di mana data dari luar masuk ke dalam sistem. Setelah melewati boundary, data dianggap sudah valid dan kode internal tidak perlu memvalidasi ulang.

flowchart LR
    subgraph "Luar Sistem"
        REQ[HTTP Request]
        ENV[Environment Variables]
        EXT[External API Response]
        DB_RAW[Database Query Result]
    end
    subgraph "Boundary Layer — Zod"
        V1[Request Schema]
        V2[Env Schema]
        V3[Response Schema]
        V4[DB Schema]
    end
    subgraph "Dalam Sistem"
        SVC[Service Layer]
        DOM[Domain Logic]
        REPO[Repository]
    end
    REQ --> V1 --> SVC
    ENV --> V2 --> SVC
    EXT --> V3 --> DOM
    DB_RAW --> V4 --> REPO
    SVC --> DOM
    DOM --> REPO

Validasi Environment Variable

Salah satu penggunaan boundary layer yang paling memberikan nilai langsung adalah validasi env var. Tanpanya, aplikasi bisa berjalan dengan konfigurasi yang salah dan baru gagal di tengah eksekusi dengan pesan error yang membingungkan.

// ANTI-PATTERN: env var diakses langsung tanpa validasi
const dbUrl = process.env.DATABASE_URL; // bisa undefined, tidak ketahuan sampai runtime
const port = parseInt(process.env.PORT!); // NaN jika PORT tidak di-set

// BENAR: validasi semua env var saat startup
const envSchema = z.object({
  DATABASE_URL: z.string().url("DATABASE_URL harus berupa URL valid"),
  PORT: z.string().transform(Number).pipe(z.number().int().min(1024).max(65535)),
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  JWT_SECRET: z.string().min(32, "JWT_SECRET minimal 32 karakter"),
  REDIS_URL: z.string().url().optional(),
});

// Panggil satu kali saat startup — aplikasi langsung crash dengan pesan jelas
// jika ada env var yang kurang atau salah format
export const env = envSchema.parse(process.env);

// Selanjutnya, gunakan env.DATABASE_URL, env.PORT — sudah type-safe dan valid

Validasi Request di API Layer

// Contoh Express middleware
const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  role: z.enum(["admin", "user"]).default("user"),
});

function validateBody<T>(schema: z.ZodType<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: "Validation failed",
        details: result.error.flatten(),
      });
    }
    req.body = result.data; // body sudah bersih dan type-safe
    next();
  };
}

app.post("/users", validateBody(createUserSchema), createUserHandler);

Validasi Response API Eksternal

// Saat mengonsumsi API eksternal, jangan pernah trust response tanpa validasi
const githubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  avatar_url: z.string().url(),
  public_repos: z.number().int().min(0),
});

async function getGithubUser(username: string) {
  const response = await fetch(`https://api.github.com/users/${username}`);
  const raw = await response.json();

  // parse() akan throw jika API mengubah kontraknya
  // safeParse() untuk handling error yang lebih graceful
  const result = githubUserSchema.safeParse(raw);
  if (!result.success) {
    throw new Error(`GitHub API response tidak sesuai: ${result.error.message}`);
  }

  return result.data; // type-safe, struktur terjamin
}

Memahami Parse Pipeline Internal

Mengetahui urutan tahap validasi Zod membantu debugging ketika chaining .transform(), .refine(), dan .pipe() menghasilkan perilaku yang tidak terduga.

flowchart TD
    A[Input Data] --> B["1. z.preprocess()\n(jika ada)"]
    B --> C["2. Base Type Check\n(string, number, object, ...)"]
    C --> D["3. Constraint Check\nmin, max, regex, email, ..."]
    D --> E["4. .refine() / .superRefine()\nCustom validation"]
    E --> F["5. .transform()\nUbah nilai"]
    F --> G["6. .pipe()\nValidasi ulang hasil transform"]
    G --> H[Output Parsed & Type-safe]
    C -- "Gagal" --> ERR[ZodError]
    D -- "Gagal" --> ERR
    E -- "Gagal" --> ERR
    G -- "Gagal" --> ERR

Implikasi praktis dari urutan ini:

// .refine() berjalan SETELAH constraint, tapi SEBELUM .transform()
// artinya di dalam refine, nilai belum ditransform
const schema = z
  .string()
  .min(1)                              // [3] constraint
  .refine((val) => val !== "admin", {  // [4] refine — val masih string asli
    message: "Username tidak boleh 'admin'",
  })
  .transform((val) => val.toLowerCase()); // [5] transform — berjalan terakhir

// .preprocess() berjalan SEBELUM segalanya — cocok untuk normalisasi input
const trimmedString = z.preprocess(
  (val) => (typeof val === "string" ? val.trim() : val), // [1] preprocess
  z.string().min(1)
);
Gunakan z.preprocess() untuk normalisasi input (trim whitespace, konversi tipe), bukan di dalam .transform(). Ini memastikan normalisasi terjadi sebelum constraint apapun dievaluasi, sehingga spasi di awal/akhir tidak menyebabkan validasi min(1) lolos untuk string yang sebenarnya kosong.

Checklist Zod Production-Grade

TIPE DATA:
  □ Gunakan z.bigint() untuk nilai yang bisa melebihi Number.MAX_SAFE_INTEGER
  □ Pilih z.unknown() bukan z.any() untuk data dari luar sistem
  □ Gunakan z.tuple() untuk array dengan struktur posisional bermakna
  □ Bedakan z.null() dan z.undefined() sesuai kontrak API

TRANSFORM DAN PIPELINE:
  □ Gunakan .pipe() setelah .transform() agar hasil transform divalidasi ulang
  □ Gunakan z.preprocess() untuk normalisasi input (trim, type coercion)
  □ Pilih .default() untuk nilai opsional, .catch() hanya untuk graceful degradation

ARSITEKTUR:
  □ Zod hanya dipanggil di boundary layer — request, env, external API, DB result
  □ Env var divalidasi satu kali saat startup dengan envSchema.parse(process.env)
  □ Response API eksternal selalu divalidasi sebelum data digunakan
  □ Global error map diset di entry point untuk konsistensi pesan error

SCHEMA KOMPLEKS:
  □ Gunakan z.lazy() untuk struktur rekursif (tree, nested comment)
  □ Tambahkan .describe() pada field yang butuh dokumentasi untuk OpenAPI
  □ z.never() dipakai untuk exhaustive checking di discriminated union

Ringkasan

  • z.bigint() untuk integer melampaui Number.MAX_SAFE_INTEGER; z.unknown() lebih aman dari z.any() karena memaksa narrowing sebelum digunakan.
  • z.tuple() untuk array dengan tipe dan posisi tetap — berbeda dengan z.array() yang homogen dan panjang bebas.
  • default() aktif saat nilai undefined, sementara catch() aktif saat validasi gagal — perbedaan ini penting dan sering menjadi sumber bug.
  • pipe() memastikan hasil .transform() divalidasi kembali oleh schema berikutnya — selalu pakai pipe() setelah transform yang mengubah tipe data.
  • z.lazy() memungkinkan schema rekursif untuk struktur tree — wajib disertai type annotation eksplisit z.ZodType<T>.
  • Global error map lewat z.setErrorMap() adalah cara yang tepat untuk i18n dan standardisasi pesan error di seluruh codebase.
  • Boundary layer pattern adalah penggunaan Zod yang paling bernilai secara arsitektural — validasi di titik masuk data, bukan di seluruh penjuru kode.
  • Parse pipeline berjalan berurutan: preprocess → type check → constraint → refine → transform → pipe. Memahami urutan ini krusial saat debugging chaining yang kompleks.

Portofolio