TYPESCRIPT ZOD DATA-VALIDATION TYPE-SAFETY FRONTEND BACKEND DEVELOPER-EXPERIENCE BEST-PRACTICES CODE-QUALITY SCHEMA-VALIDATION

Zod Lanjutan: Menguasai Validasi Skema Data Holistik di Aplikasi TypeScript Anda

⏱️ 13 menit baca
👨‍💻

Zod Lanjutan: Menguasai Validasi Skema Data Holistik di Aplikasi TypeScript Anda

Halo para developer! Pernahkah Anda merasa frustrasi dengan data yang tidak konsisten? Entah itu input dari pengguna, respons dari API eksternal, atau bahkan konfigurasi aplikasi Anda sendiri, data yang tidak valid adalah sumber bug yang tak ada habisnya. Di dunia TypeScript, kita punya interface dan type untuk memastikan struktur data yang kita harapkan. Tapi bagaimana jika data itu datang dari “luar” dan tidak sesuai dengan type kita? Di sinilah Zod bersinar.

Anda mungkin sudah familiar dengan Zod untuk validasi API di backend atau untuk memastikan konsistensi tipe end-to-end. Namun, Zod memiliki kekuatan jauh melampaui itu. Artikel ini akan membawa Anda menyelami fitur-fitur Zod yang lebih canggih untuk validasi data holistik di seluruh lapisan aplikasi TypeScript Anda. Siap untuk membangun aplikasi yang lebih robust dan bebas drama? Mari kita mulai!

1. Pendahuluan: Kenapa Zod Begitu Penting?

Di era aplikasi modern, data mengalir dari berbagai sumber: formulir pengguna, API pihak ketiga, database, file konfigurasi, dan banyak lagi. Tanpa validasi yang tepat, data ini bisa menjadi bom waktu yang siap meledak sebagai bug runtime. TypeScript memang membantu kita di compile-time, tetapi tidak bisa menjamin validitas data yang datang dari luar ekosistem TypeScript (misalnya, JSON dari network request).

Zod hadir sebagai jembatan antara dunia runtime dan compile-time. Ia memungkinkan kita mendefinisikan “schema” atau cetak biru data menggunakan sintaksis yang intuitif dan ekspresif. Begitu data divalidasi dengan Zod, TypeScript secara otomatis akan mengetahui tipe data tersebut, memberi kita jaminan keamanan tipe yang kuat.

📌 Poin Penting: Zod adalah library validasi skema yang TypeScript-first. Artinya, ia tidak hanya memvalidasi data saat runtime, tetapi juga menginferensikan tipe TypeScript yang akurat dari skema yang Anda definisikan. Ini adalah game-changer untuk developer TypeScript.

2. Zod Bukan Hanya untuk API: Memperluas Cakupan Validasi Anda

Dua artikel sebelumnya tentang Zod di blog ini fokus pada validasi API dan konsistensi E2E. Itu memang kasus penggunaan yang sangat umum dan powerful. Namun, mari kita pikirkan area lain di mana Zod bisa menjadi penyelamat:

Dengan Zod, kita bisa memiliki satu sumber kebenaran (single source of truth) untuk definisi skema data kita, yang kemudian bisa digunakan di frontend, backend, bahkan tooling.

3. Dasar-dasar Zod (Review Singkat)

Sebelum melangkah lebih jauh, mari kita ulas sedikit dasar-dasar Zod:

import { z } from 'zod';

// Skema untuk string, angka, boolean
const namaSchema = z.string().min(3, "Nama minimal 3 karakter");
const usiaSchema = z.number().int().positive("Usia harus bilangan bulat positif");
const aktifSchema = z.boolean();

// Skema objek
const userSchema = z.object({
  id: z.string().uuid(),
  nama: namaSchema,
  email: z.string().email("Format email tidak valid"),
  usia: usiaSchema.optional(), // Opsional
  roles: z.array(z.enum(["admin", "editor", "viewer"])),
});

// Inferensi tipe dari skema
type User = z.infer<typeof userSchema>;
// type User = {
//     id: string;
//     nama: string;
//     email: string;
//     usia?: number | undefined;
//     roles: ("admin" | "editor" | "viewer")[];
// }

// Contoh penggunaan
const dataValid = {
  id: "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  nama: "Budi Santoso",
  email: "budi@example.com",
  roles: ["editor"],
};

const dataInvalid = {
  id: "invalid-uuid",
  nama: "Bu",
  email: "budi@invalid",
  roles: ["hacker"],
};

try {
  const user = userSchema.parse(dataValid);
  console.log("Data valid:", user);
} catch (error) {
  console.error("Data tidak valid:", error);
}
// Output: Data valid: { id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef', nama: 'Budi Santoso', email: 'budi@example.com', roles: [ 'editor' ] }

try {
  userSchema.parse(dataInvalid);
} catch (error) {
  console.error("Data tidak valid:", error.errors);
}
/*
Output: Data tidak valid: [
  {
    code: 'invalid_string',
    validation: 'uuid',
    message: 'Invalid uuid',
    path: [ 'id' ]
  },
  {
    code: 'too_small',
    minimum: 3,
    type: 'string',
    inclusive: true,
    exact: false,
    message: 'Nama minimal 3 karakter',
    path: [ 'nama' ]
  },
  {
    code: 'invalid_string',
    validation: 'email',
    message: 'Format email tidak valid',
    path: [ 'email' ]
  },
  {
    code: 'invalid_enum_value',
    received: 'hacker',
    options: [ 'admin', 'editor', 'viewer' ],
    message: 'Invalid enum value. Expected \'admin\' | \'editor\' | \'viewer\', received \'hacker\'',
    path: [ 'roles', 0 ]
  }
]
*/

Ini adalah dasar-dasarnya. Zod menyediakan banyak validator bawaan untuk berbagai tipe data dan format (URL, UUID, tanggal, dll.).

4. Transformasi Data dengan .transform() dan z.coerce

Seringkali, data yang kita terima tidak dalam format yang kita inginkan. Misalnya, angka bisa datang sebagai string, atau tanggal sebagai string ISO. Zod memungkinkan kita untuk membersihkan dan mengubah data ini sebagai bagian dari proses validasi.

4.1. Menggunakan z.coerce

z.coerce adalah utilitas Zod yang secara otomatis mencoba mengkonversi nilai ke tipe yang ditentukan jika memungkinkan. Ini sangat berguna untuk input form.

const angkaDariInput = z.coerce.number().min(0, "Harus angka positif");
const tanggalDariInput = z.coerce.date();

console.log(angkaDariInput.parse("123")); // 123 (number)
console.log(angkaDariInput.parse(456));   // 456 (number)
// console.log(angkaDariInput.parse("abc")); // Throws ZodError

console.log(tanggalDariInput.parse("2023-10-26T10:00:00Z")); // Date object
console.log(tanggalDariInput.parse(new Date()));             // Date object

4.2. Menggunakan .transform()

Untuk transformasi yang lebih kompleks atau kustom, Anda bisa menggunakan .transform(). Ini memungkinkan Anda untuk memanipulasi nilai setelah validasi dasar berhasil.

const passwordSchema = z.string()
  .min(8, "Password minimal 8 karakter")
  .transform(val => val.trim()); // Hapus spasi di awal/akhir

const usernameSchema = z.string()
  .toLowerCase() // Ubah ke huruf kecil
  .trim()        // Hapus spasi
  .min(3, "Username minimal 3 karakter");

type UserInput = z.infer<typeof userInputSchema>;
// type UserInput = {
//     username: string;
//     password: string;
//     full_name: string;
//     email: string;
// }

const userInputSchema = z.object({
  username: usernameSchema,
  password: passwordSchema,
  full_name: z.string().transform(val => val.toUpperCase()), // Ubah nama jadi uppercase
  email: z.string().email(),
});

const rawInput = {
  username: "  JOHNDOE ",
  password: "  mysecretpassword  ",
  full_name: "John Doe",
  email: "john.doe@example.com",
};

const processedInput = userInputSchema.parse(rawInput);
console.log(processedInput);
/*
Output: {
  username: 'johndoe',
  password: 'mysecretpassword',
  full_name: 'JOHN DOE',
  email: 'john.doe@example.com'
}
*/

💡 Tips Praktis: Gunakan .transform() untuk membersihkan data (trim, lowercase, uppercase) atau mengonversi format (misalnya, string ke angka/tanggal) segera setelah validasi. Ini menjaga data Anda tetap konsisten.

5. Validasi Kustom dan Refinements: .refine() dan .superRefine()

Zod menyediakan banyak validator bawaan, tetapi terkadang Anda memerlukan logika validasi yang sangat spesifik yang tidak ada di validator standar. Di sinilah .refine() dan .superRefine() menjadi sangat berguna.

5.1. .refine(): Validasi Kustom Sederhana

refine cocok untuk validasi yang hanya menghasilkan satu pesan error.

const passwordConfirmSchema = z.object({
  password: z.string().min(8, "Password minimal 8 karakter"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Password dan konfirmasi password tidak cocok",
  path: ["confirmPassword"], // Tentukan path error agar lebih spesifik
});

try {
  passwordConfirmSchema.parse({ password: "password123", confirmPassword: "password123" });
  console.log("Password cocok!");
} catch (error) {
  console.error("Error:", error.errors);
}

try {
  passwordConfirmSchema.parse({ password: "password123", confirmPassword: "password1234" });
} catch (error) {
  console.error("Error:", error.errors);
}
// Output: Error: [ { code: 'custom', fatal: true, message: 'Password dan konfirmasi password tidak cocok', path: [ 'confirmPassword' ] } ]

Perhatikan penggunaan path di objek refine untuk mengarahkan pesan error ke field yang relevan.

5.2. .superRefine(): Validasi Kustom Lanjutan dengan Multiple Errors

superRefine adalah versi yang lebih kuat dari refine, memungkinkan Anda untuk menambahkan beberapa error ke konteks validasi, bahkan untuk field yang berbeda. Ini ideal untuk validasi yang kompleks atau saling terkait.

const registrationSchema = z.object({
  username: z.string().min(3, "Username minimal 3 karakter"),
  email: z.string().email("Format email tidak valid"),
  password: z.string().min(8, "Password minimal 8 karakter"),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  // Validasi 1: Password dan konfirmasi password harus cocok
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Password dan konfirmasi password tidak cocok",
      path: ["confirmPassword"],
    });
  }
  // Validasi 2: Username tidak boleh mengandung email
  if (data.username.includes(data.email.split('@')[0])) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Username tidak boleh mengandung bagian dari email Anda",
      path: ["username"],
    });
  }
});

try {
  registrationSchema.parse({
    username: "john.doe",
    email: "john.doe@example.com",
    password: "password123",
    confirmPassword: "password123",
  });
  console.log("Registrasi berhasil!");
} catch (error) {
  console.error("Error:", error.errors);
}

try {
  registrationSchema.parse({
    username: "johndoe", // mengandung "john.doe" dari email
    email: "john.doe@example.com",
    password: "password123",
    confirmPassword: "password1234", // tidak cocok
  });
} catch (error) {
  console.error("Error:", error.errors);
}
/*
Output: Error: [
  {
    code: 'custom',
    message: 'Password dan konfirmasi password tidak cocok',
    path: [ 'confirmPassword' ]
  },
  {
    code: 'custom',
    message: 'Username tidak boleh mengandung bagian dari email Anda',
    path: [ 'username' ]
  }
]
*/

superRefine memberi Anda kontrol penuh atas pesan error dan path-nya, menjadikannya sangat fleksibel.

6. Skema Rekursif dan Discriminated Unions: Menangani Data Kompleks

Data di dunia nyata seringkali tidak datar. Kita berhadapan dengan struktur bersarang atau data yang bisa memiliki beberapa bentuk berbeda. Zod siap untuk ini.

6.1. Skema Rekursif: Data Bersarang Tanpa Batas

Skema rekursif digunakan untuk mendefinisikan struktur data yang merujuk pada dirinya sendiri, seperti pohon atau komentar bersarang.

type Comment = z.infer<typeof commentSchema>; // Awalnya akan error karena commentSchema belum didefinisikan

const commentSchema: z.ZodSchema<Comment> = z.object({
  id: z.string().uuid(),
  author: z.string(),
  content: z.string().min(1),
  replies: z.lazy(() => z.array(commentSchema)).default([]), // Menggunakan z.lazy
});

const postSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(5),
  content: z.string(),
  comments: z.array(commentSchema).default([]),
});

const blogPost = postSchema.parse({
  id: "post-1",
  title: "Belajar Zod Rekursif",
  content: "Content...",
  comments: [
    {
      id: "comment-1",
      author: "Alice",
      content: "Artikel bagus!",
      replies: [
        {
          id: "comment-2",
          author: "Bob",
          content: "Setuju sekali!",
        },
      ],
    },
    {
      id: "comment-3",
      author: "Charlie",
      content: "Terima kasih.",
    },
  ],
});

console.log(JSON.stringify(blogPost, null, 2));

⚠️ Perhatian: Untuk skema rekursif, Anda harus menggunakan z.lazy(() => ...) untuk menunda evaluasi skema, mencegah circular dependency atau reference error.

6.2. Discriminated Unions: Data dengan Bentuk Berbeda

Discriminated unions adalah pola di mana objek dapat memiliki beberapa bentuk, dan ada satu field (“discriminator”) yang nilainya menentukan bentuk objek tersebut.

const textMessageSchema = z.object({
  type: z.literal("text"),
  content: z.string(),
});

const imageMessageSchema = z.object({
  type: z.literal("image"),
  url: z.string().url(),
  altText: z.string().optional(),
});

const videoMessageSchema = z.object({
  type: z.literal("video"),
  url: z.string().url(),
  durationSec: z.number().positive(),
});

const chatMessageSchema = z.discriminatedUnion("type", [
  textMessageSchema,
  imageMessageSchema,
  videoMessageSchema,
]);

type ChatMessage = z.infer<typeof chatMessageSchema>;
// type ChatMessage = {
//     type: "text";
//     content: string;
// } | {
//     type: "image";
//     url: string;
//     altText?: string | undefined;
// } | {
//     type: "video";
//     url: string;
//     durationSec: number;
// }

const message1 = chatMessageSchema.parse({
  type: "text",
  content: "Halo, apa kabar?",
});
console.log(message1);

const message2 = chatMessageSchema.parse({
  type: "image",
  url: "https://example.com/foto.jpg",
});
console.log(message2);

try {
  chatMessageSchema.parse({
    type: "image",
    url: "invalid-url", // akan error
  });
} catch (error) {
  console.error("Error:", error.errors);
}

z.discriminatedUnion sangat powerful karena Zod (dan TypeScript) akan secara cerdas menyempitkan tipe berdasarkan nilai discriminator field. Ini sangat membantu untuk membangun aplikasi yang robust dengan data yang polimorfik.

7. Validasi Konfigurasi Aplikasi: Mengamankan process.env Anda

Seberapa sering Anda menghadapi error karena variabel lingkungan (environment variables) yang hilang atau salah format? Zod bisa menjadi penjaga gerbang konfigurasi aplikasi Anda.

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().int().min(3000).max(65535).default(3000),
  DATABASE_URL: z.string().url("Format URL database tidak valid"),
  API_KEY: z.string().min(16, "API_KEY minimal 16 karakter"),
  ENABLE_FEATURE_X: z.coerce.boolean().default(false),
});

type Env = z.infer<typeof envSchema>;

// Contoh dummy process.env (di aplikasi nyata, ini akan dibaca dari proses)
const rawEnv = {
  NODE_ENV: "production",
  PORT: "8080",
  DATABASE_URL: "postgresql://user:pass@host:5432/db",
  API_KEY: "supersecretkey123456",
  // ENABLE_FEATURE_X tidak ada, akan menggunakan default
};

try {
  const env = envSchema.parse(rawEnv);
  console.log("Konfigurasi ENV valid:", env);
  // Di sini, 'env' sudah type-safe dan terjamin validitasnya
  console.log("Aplikasi berjalan di mode:", env.NODE_ENV);
  console.log("Port:", env.PORT);
} catch (error) {
  console.error("Kesalahan validasi konfigurasi ENV:", error.errors);
  process.exit(1); // Hentikan aplikasi jika konfigurasi tidak valid
}

// Contoh ENV yang tidak valid
const invalidEnv = {
  NODE_ENV: "staging", // tidak ada di enum
  PORT: "abc",         // bukan angka
  DATABASE_URL: "invalid-url",
  API_KEY: "short",
};

try {
  envSchema.parse(invalidEnv);
} catch (error) {
  console.error("Kesalahan validasi konfigurasi ENV:", error.errors);
}
/*
Output: Kesalahan validasi konfigurasi ENV: [
  {
    code: 'invalid_enum_value',
    received: 'staging',
    options: [ 'development', 'production', 'test' ],
    message: 'Invalid enum value. Expected \'development\' | \'production\' | \'test\', received \'staging\'',
    path: [ 'NODE_ENV' ]
  },
  {
    code: 'invalid_type',
    expected: 'number',
    received: 'nan',
    message: 'Expected number, received nan',
    path: [ 'PORT' ]
  },
  {
    code: 'invalid_string',
    validation: 'url',
    message: 'Format URL database tidak valid',
    path: [ 'DATABASE_URL' ]
  },
  {
    code: 'too_small',
    minimum: 16,
    type: 'string',
    inclusive: true,
    exact: false,
    message: 'API_KEY minimal 16 karakter',
    path: [ 'API_KEY' ]
  }
]
*/
Dengan ini, Anda bisa memastikan aplikasi Anda hanya akan berjalan dengan konfigurasi yang benar dan aman.

## Kesimpulan

Zod lebih dari sekadar library validasi. Ini adalah alat fundamental untuk membangun aplikasi TypeScript yang kuat, aman, dan mudah di-maintain. Dengan menguasai `.transform()`, `z.coerce`, `.refine()`, `.superRefine()`, `z.lazy()`, dan `z.discriminatedUnion`, Anda bisa menangani hampir semua skenario validasi data, dari yang sederhana hingga yang paling kompleks.

Mulai sekarang, jangan hanya mengandalkan TypeScript di compile-time. Bawa jaminan tipe Anda ke runtime dengan Zod untuk data yang datang dari mana saja. Aplikasi Anda akan lebih tangguh, lebih mudah di-debug, dan developer experience Anda akan meningkat drastis!

Selamat mencoba, dan jangan ragu untuk bereksperimen dengan kekuatan penuh Zod!