Validasi Antar Field Dengan Zod Dan React Hook Form
3 min read

Validasi Antar Field Dengan Zod Dan React Hook Form

Dalam pengembangan form modern, validasi jarang berdiri sendiri pada satu field. Hampir selalu ada kebutuhan seperti:

  • Konfirmasi password harus sama dengan password
  • End date harus lebih besar dari start date
  • Field tertentu wajib diisi jika checkbox aktif
  • Struktur field berubah tergantung pilihan user

Artikel ini membahas secara mendalam bagaimana melakukan validasi berdasarkan field lain menggunakan Zod dan React Hook Form (RHF), termasuk pendekatan sederhana hingga arsitektur yang scalable untuk form kompleks.


Konsep Dasar: Cross-Field Validation

Cross-field validation adalah validasi yang membutuhkan lebih dari satu field untuk menentukan valid atau tidaknya suatu nilai.

Contoh sederhana:

  • confirmPassword valid hanya jika sama dengan password
  • companyName required hanya jika isCompany = true

Zod menyediakan beberapa mekanisme untuk ini:

  • .refine() → untuk validasi sederhana
  • .superRefine() → untuk validasi kompleks dengan kontrol penuh
  • z.discriminatedUnion() → untuk struktur conditional berbasis enum

Menggunakan .refine() (Kasus Sederhana)

Gunakan .refine() ketika:

  • Hanya ada satu kondisi
  • Hanya perlu satu error
  • Tidak perlu multiple issue dalam satu validasi

Contoh: Konfirmasi Password

import { z } from "zod";

const schema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Password tidak sama",
    path: ["confirmPassword"],
  });

Kenapa path penting?

Tanpa path, error akan muncul di level root form. Dengan path, error diarahkan langsung ke field tertentu.


Menggunakan .superRefine() (Kontrol Penuh)

Gunakan .superRefine() ketika:

  • Ada banyak kondisi
  • Bisa muncul multiple error sekaligus
  • Perlu kontrol granular terhadap issue

Contoh: Conditional Required Field

const schema = z.object({
  isCompany: z.boolean(),
  companyName: z.string().optional(),
}).superRefine((data, ctx) => {
  if (data.isCompany && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Nama perusahaan wajib diisi",
      path: ["companyName"],
    });
  }
});

Kenapa pakai .optional()?

Karena jika companyName selalu required di schema utama, validasi akan gagal sebelum conditional logic dijalankan.


Validasi Relasi Antar Nilai (Date Range)

Contoh umum dalam sistem booking, event, atau filter:

const schema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine((data) => data.endDate > data.startDate, {
  message: "End date harus setelah start date",
  path: ["endDate"],
});

Best Practice

Selalu arahkan error ke field yang dianggap “salah”, biasanya field kedua.


Integrasi dengan React Hook Form

Zod digunakan melalui resolver:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const form = useForm({
  resolver: zodResolver(schema),
  mode: "onChange",
});

Catatan Penting

Jika menggunakan form dynamic (show/hide field):

  • Field tetap divalidasi walaupun tidak dirender
  • Gunakan .optional() untuk field conditional
  • Atau gunakan discriminated union (lebih scalable)

Arsitektur Lebih Scalable: discriminatedUnion

Ketika satu field menentukan struktur form secara keseluruhan, gunakan pendekatan ini.

Contoh: Payment Method

const schema = z.discriminatedUnion("paymentMethod", [
  z.object({
    paymentMethod: z.literal("credit_card"),
    cardNumber: z.string().min(1),
  }),
  z.object({
    paymentMethod: z.literal("bank_transfer"),
    bankAccount: z.string().min(1),
  }),
]);

Kenapa Ini Lebih Baik?

  • Tidak perlu conditional manual
  • Tidak perlu .optional() workaround
  • Struktur type lebih bersih
  • Lebih aman untuk refactor
  • Lebih cocok untuk form kompleks atau multi-step

Kapan Menggunakan Apa?

KebutuhanGunakan
Validasi 1 kondisi sederhanarefine
Banyak kondisi & multiple errorsuperRefine
Struktur berubah berdasarkan enumdiscriminatedUnion
Form sangat dynamicdiscriminatedUnion + optional

Kesalahan Umum yang Sering Terjadi

1. Lupa .optional() pada field conditional

Field tetap dianggap required walaupun secara UI disembunyikan.

2. Error muncul di root form

Karena tidak menggunakan path.

3. Logic terlalu banyak di UI

Validasi seharusnya di schema, bukan di component.

4. Menggunakan superRefine untuk semua hal

Padahal discriminatedUnion lebih clean dan maintainable.


Perspektif Arsitektur

Cross-field validation bukan hanya soal teknis, tapi soal desain.

Pertanyaan yang perlu dipikirkan:

  • Apakah validasi ini bagian dari domain rule?
  • Apakah struktur data berubah berdasarkan state tertentu?
  • Apakah validasi ini akan berkembang ke depan?

Jika jawabannya ya, maka pendekatan schema-based seperti Zod akan jauh lebih maintainable dibanding validasi manual di UI.


Penutup

Zod memberikan fleksibilitas penuh untuk melakukan validasi antar field, mulai dari kasus sederhana hingga struktur form kompleks.

Ringkasnya:

  • Gunakan refine() untuk kondisi sederhana
  • Gunakan superRefine() untuk kontrol penuh
  • Gunakan discriminatedUnion() untuk arsitektur scalable

Dengan pendekatan yang tepat, form complex sekalipun bisa tetap clean, predictable, dan maintainable.

Validasi bukan sekadar memastikan data benar — tetapi memastikan sistem tetap konsisten seiring pertumbuhan kompleksitas.