TypeScript ngecek tipe cuma pas kamu nulis kode. Begitu dicompile, semua info tipe itu lenyap dan yang jalan cuma JavaScript polos. Celah ini baru kerasa waktu data beneran masuk: respons dari API, isi form, atau hasil JSON.parse. Nggak ada yang menjamin field umur itu beneran number, bukan string "28" dari input HTML. Zod nutup celah ini. Kamu tulis schema sekali, dia validasi data di runtime dan sekaligus nge-generate tipe statisnya. Nggak perlu nulis tipe dua kali.

Tipe Statis Cuma Ada Saat Compile

Coba sendiri dulu biar kebayang. Kode TypeScript di bawah lolos compile padahal di runtime hasilnya ngaco.

typescript
type User = { umur: number };

// Baris ini error saat compile — tsc nolak (bagus):
// const salah: User = { umur: "28" };

// Tapi di runtime, JSON.parse nggak peduli soal tipe:
const dariApi: User = JSON.parse('{"umur": "28"}');
console.log(dariApi.umur + 1); // "281", bukan 29

Di Python, masalah persis ini sudah lama dipecahkan Pydantic — kamu bisa baca tutorial Pydantic kalau main di Python. Zod adalah padanannya untuk JavaScript/TypeScript. Kalau kamu masih baru sama tipe-tipe TypeScript, mampir dulu ke panduan TypeScript biar nggak kaget sama istilah seperti type dan interface.

Install dan Tulis Schema Pertama

Zod jalan di Node, Deno, Bun, dan browser — tinggal install lewat package manager yang kamu pakai.

bash
npm install zod
# atau
bun add zod
# atau
pnpm add zod

Schema paling dasar begini bentuknya. Kamu deklarasiin bentuk data yang diharapkan, lalu panggil parse buat ngecek data asli.

typescript
import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
});

const data = { name: "Budi", email: "budi@example.com", age: 28 };

const result = UserSchema.parse(data);
console.log(result);
// { name: 'Budi', email: 'budi@example.com', age: 28 }

Kalau datanya valid, parse ngembaliin data yang sama. Kalau nggak valid, dia lempar error dan program kamu berhenti — itu kenapa di bagian selanjutnya kita pakai safeParse untuk data yang nggak bisa dipercaya.

Dapat Tipe Statis Gratis dengan z.infer

Bagian paling enak dari Zod: schema yang kamu tulis bisa dipakai buat nge-generate tipe TypeScript. Nggak ada definisi tipe ganda yang bisa meleset dari schema-nya.

typescript
import { z } from "zod";

const OrderSchema = z.object({
  id: z.string().uuid(),
  items: z
    .array(
      z.object({
        name: z.string(),
        qty: z.number().int().min(1),
      })
    )
    .min(1),
});

// Tipe ini di-generate dari schema, bukan ditulis manual
type Order = z.infer<typeof OrderSchema>;

function cetakOrder(order: Order) {
  const total = order.items.reduce((sum, item) => sum + item.qty, 0);
  console.log(`${order.id}: ${total} item`);
}

const hasil = OrderSchema.parse({
  id: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  items: [
    { name: "Keyboard", qty: 1 },
    { name: "Mouse", qty: 2 },
  ],
});

cetakOrder(hasil);
// 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d: 3 item

Jangan Pakai parse untuk Data yang Nggak Kamu Percaya

parse lempar exception. Buat data dari luar — request HTTP, form user, file — itu resep crash. Pakai safeParse, yang ngembaliin object hasil alih-alih melempar error.

typescript
import { z } from "zod";

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const input = { email: "bukan-email", password: "123" };

const result = LoginSchema.safeParse(input);

if (result.success) {
  console.log("login ok", result.data);
} else {
  for (const issue of result.error.issues) {
    console.log(`${issue.path.join(".")}: ${issue.message}`);
  }
}
// email: Invalid email
// password: String must contain at least 8 character(s)

Aturan simpelnya: parse buat data yang kamu yakin valid (misal config yang kamu tulis sendiri), safeParse buat segala sesuatu yang datang dari user atau jaringan. Satu parse di endpoint yang salah bisa bikin seluruh request gagal.

Refine, Transform, dan Discriminated Union

Tiga fitur ini yang bikin Zod lebih dari sekadar pengecek tipe primitif. refine buat aturan validasi buatan sendiri, transform buat ngubah nilai saat validasi, dan discriminatedUnion buat data yang bentuknya bercabang.

typescript
import { z } from "zod";

// refine: aturan validasi sendiri
const PasswordSchema = z
  .string()
  .min(8)
  .refine((v) => /[A-Z]/.test(v) && /\d/.test(v), {
    message: "Password harus punya huruf besar dan angka",
  });

PasswordSchema.parse("rahasia123"); // error
PasswordSchema.parse("Rahasia123"); // ok

// transform (via coerce): ubah nilai saat validasi
const UmurDariForm = z.coerce.number().int().min(17);
console.log(UmurDariForm.parse("28") + 1); // 29 — input "28" jadi number

// discriminatedUnion: cabang berdasarkan field method
const PembayaranSchema = z.discriminatedUnion("method", [
  z.object({ method: z.literal("kartu"), nomor: z.string().length(16) }),
  z.object({ method: z.literal("transfer"), rekening: z.string().min(6) }),
]);

PembayaranSchema.parse({ method: "kartu", nomor: "4111111111111111" });
PembayaranSchema.parse({ method: "transfer", rekening: "1234567890" });
// PembayaranSchema.parse({ method: "kartu", rekening: "x" }); // error

Dipasang di Form dan API

Contoh nyata yang paling sering ketemu: form pendaftaran yang ngirim data ke API. FormData selalu ngasih string, jadi z.coerce.number() penting banget di sini. Setelah data lolos validasi, kamu bisa langsung pakai hasilnya dengan percaya diri — tipe-nya udah bener.

typescript
import { z } from "zod";

const FormPendaftaranSchema = z.object({
  nama: z.string().min(2, "Nama minimal 2 karakter"),
  email: z.string().email(),
  umur: z.coerce.number().int().min(17, "Minimal 17 tahun"),
});

async function kirimForm() {
  const form = document.querySelector("form")!;
  const dataForm = new FormData(form);

  const parsed = FormPendaftaranSchema.safeParse({
    nama: dataForm.get("nama"),
    email: dataForm.get("email"),
    umur: dataForm.get("umur"), // string dari FormData
  });

  if (!parsed.success) {
    parsed.error.issues.forEach((issue) => {
      console.error(issue.path.join("."), issue.message);
    });
    return;
  }

  const res = await fetch("/api/daftar", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(parsed.data),
  });
  console.log(res.status);
}

Pola yang sama kepakai di data layer. Tipe hasil z.infer bisa langsung jadi tipe entity yang kamu oper ke ORM. Kalau kamu kerja sama database lewat TypeScript, tutorial Prisma ORM bisa jadi lanjutan yang pas — validasi input pakai Zod, lalu simpan lewat Prisma.

Biasakan definisi schema Zod di satu file terpisah (misal schemas.ts) lalu import z.infer-nya ke mana-mana. Begitu aturan validasi berubah, tipe statis ikut berubah otomatis — nggak ada lagi tipe yang ketinggalan update.

Apa bedanya Zod dengan validasi TypeScript biasa?

TypeScript cuma ngecek tipe saat compile, dan seluruh informasinya hilang begitu kode dijalankan. Zod memvalidasi data yang sebenarnya di runtime — misalnya isi respons API — sekaligus menghasilkan tipe statis dari schema yang sama. Jadi Zod menambah lapisan keamanan yang nggak bisa dikasih TypeScript sendirian.

Zod cuma buat backend, atau bisa juga di frontend?

Bisa dua-duanya. Di backend, Zod dipakai buat memvalidasi body request. Di frontend, Zod sering dipasang buat ngecek isi form sebelum dikirim, atau nge-parse data yang diterima dari API. Schema yang sama bahkan bisa dibagikan antara frontend dan backend lewat monorepo.

Coba pasang Zod di satu endpoint yang paling sering kena input aneh dari klien. Mulai dari safeParse, bukan parse, biar server nggak mati gara-gara data yang nggak valid. Kalau udah lancar, naik ke z.coerce dan discriminatedUnion buat payload yang lebih rumit.