Kenapa ini penting
Semua yang kamu pelajari sejauh ini bekerja di compile time. Masalahnya, data paling berbahaya di aplikasimu datang dari luar dan baru ada saat runtime: respons API, isi form, query string URL, localStorage, webhook dari payment gateway. Kalau kamu menulis const user = (await res.json()) as User, kamu sedang berbohong kecil kepada TypeScript. Compiler percaya, editor memberi autocomplete, tapi kalau server ternyata mengirim harga sebagai string atau nama bernilai null, aplikasimu tetap meledak, kali ini dengan rasa aman yang palsu.
Analoginya seperti satpam yang hanya membaca daftar tamu di kertas tanpa pernah mengecek KTP orang yang datang. Daftar tamunya rapi (tipe), tapi siapa pun yang mengaku bernama "Budi" bisa masuk. Validasi runtime adalah pengecekan KTP itu. Di modul ini kamu menggabungkan keduanya dengan Zod (versi 4.6.7), library yang sudah kamu kenal di materi Library: satu skema yang memvalidasi data saat runtime sekaligus menghasilkan tipe TypeScript, sehingga keduanya tidak pernah tidak sinkron.
Konsep inti
Kenapa res.json() bertipe Promise<any>
Response.json() di tipe bawaan DOM mengembalikan Promise<any>. Artinya hasil fetch langsung "menular" any ke seluruh kodemu kalau tidak hati-hati. Ada tiga tingkat keamanan:
| Cara | Contoh | Keamanan |
|---|---|---|
Biarkan any |
const data = await res.json() |
Tidak ada pemeriksaan sama sekali |
| Assertion | const data = (await res.json()) as User[] |
Autocomplete ada, tapi hanya janji |
| Validasi runtime | const data = SkemaUser.array().parse(await res.json()) |
Diperiksa sungguhan, tipe dijamin |
Untuk prototipe cepat, assertion kadang cukup. Untuk aplikasi yang dipakai orang lain, validasi di batas aplikasi (tempat data masuk) adalah praktik standar.
Skema Zod sebagai satu sumber kebenaran
import { z } from "zod";
const SkemaProduk = z.object({
id: z.number().int().positive(),
nama: z.string().min(1),
harga: z.number().nonnegative(),
kategori: z.enum(["makanan", "minuman"]),
gambar: z.url().optional(),
});
type Produk = z.infer<typeof SkemaProduk>;
// { id: number; nama: string; harga: number; kategori: "makanan" | "minuman"; gambar?: string | undefined }
z.infer<typeof Skema> menurunkan tipe TypeScript dari skema. Kamu tidak menulis type Produk secara manual. Tambah satu field di skema, tipenya ikut berubah. Di Zod 4, validator format seperti z.email(), z.url(), dan z.uuid() tersedia langsung di z (bentuk lama z.string().email() masih ada tetapi ditandai deprecated).
parse vs safeParse
Skema.parse(data): mengembalikan data bertipe jika valid, melemparZodErrorjika tidak.Skema.safeParse(data): tidak melempar; mengembalikan discriminated union{ success: true, data }atau{ success: false, error }. Cocok saat kamu ingin menangani kegagalan secara eksplisit.
Perhatikan bahwa hasil safeParse adalah discriminated union, persis pola yang kamu pelajari di modul Union & Narrowing. Setelah if (!hasil.success), TypeScript tahu hasil.data tersedia.
z.input vs z.output dan transformasi
Skema bisa mengubah data saat validasi, misalnya string tanggal menjadi Date atau string angka dari query URL menjadi number:
import { z } from "zod";
const SkemaQuery = z.object({
halaman: z.coerce.number().int().min(1).default(1),
});
Dalam kasus seperti ini tipe "sebelum" dan "sesudah" berbeda. z.input<typeof S> adalah bentuk sebelum diproses, z.output<typeof S> (sama dengan z.infer) adalah bentuk setelahnya.
Di mana validasi dilakukan
Validasi sekali, di pintu masuk:
Server API ──► fetchJson(url, Skema) ──► data bertipe & tervalidasi ──► komponen React
▲
di sini satu-satunya tempat data "liar" disentuh
Komponen tidak perlu tahu soal validasi; mereka menerima data yang sudah pasti benar. Pola ini juga berlaku untuk localStorage, searchParams, dan FormData.
Membuat function fetch generik
Gabungkan generics dan Zod: function yang menerima URL dan skema, lalu mengembalikan tipe yang diturunkan dari skema.
import { z } from "zod";
async function ambilJson<S extends z.ZodType>(url: string, skema: S): Promise<z.infer<S>> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return skema.parse(await res.json());
}
Pemanggil cukup menulis ambilJson(url, SkemaProduk.array()) dan hasilnya otomatis bertipe Produk[], tanpa as sama sekali.
Kapan tipe saja sudah cukup
Validasi runtime punya biaya (kode tambahan, sedikit waktu proses). Kamu tidak perlu memvalidasi data yang sepenuhnya dikendalikan kodemu sendiri, seperti object konstanta atau data antar-komponen. Validasi wajib untuk data yang melewati batas kepercayaan: jaringan, input user, penyimpanan browser, dan layanan pihak ketiga. Di Next.js nanti, hal yang sama berlaku untuk body request di Route Handler dan Server Action.
Contoh kode
// api.ts, fetch bertipe + validasi runtime dengan Zod 4
import { z } from "zod";
// 1. Skema = satu sumber kebenaran
const SkemaTodo = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
export type Todo = z.infer<typeof SkemaTodo>;
// 2. Error kustom supaya pemanggil bisa membedakan jenis kegagalan
export class ApiError extends Error {
readonly status: number;
constructor(status: number, pesan: string) {
super(pesan);
this.name = "ApiError";
this.status = status;
}
}
// 3. Function fetch generik: tipe hasil diturunkan dari skema
export async function ambilJson<S extends z.ZodType>(url: string, skema: S): Promise<z.infer<S>> {
const res = await fetch(url);
if (!res.ok) throw new ApiError(res.status, `Gagal memuat ${url}`);
const mentah: unknown = await res.json();
const hasil = skema.safeParse(mentah);
if (!hasil.success) {
throw new Error(`Data dari server tidak sesuai:\n${z.prettifyError(hasil.error)}`);
}
return hasil.data;
}
// 4. Pemakaian: tanpa 'as', tipe todos otomatis Todo[]
const todos = await ambilJson("https://jsonplaceholder.typicode.com/todos?_limit=3", z.array(SkemaTodo));
console.log(todos.map((t) => `${t.completed ? "✅" : "⬜"} ${t.title}`).join("\n"));
// → ⬜ delectus aut autem
// → ⬜ quis ut nam facilis et officia qui
// → ⬜ fugiat veniam minus
console.log(todos[0]?.judul); // ❌ error TS2339: Property 'judul' does not exist on type '{ userId: number; id: number; title: string; completed: boolean; }'.
Sekarang lihat apa yang terjadi saat server mengirim data yang tidak sesuai. Ini persis kasus yang tidak bisa ditangkap TypeScript sendirian:
// validasi.ts, data "liar" dari server
import { z } from "zod";
const SkemaProduk = z.object({
id: z.number(),
nama: z.string().min(1, "Nama produk wajib diisi"),
harga: z.number().nonnegative("Harga tidak boleh negatif"),
dibuatPada: z.iso.datetime().transform((s) => new Date(s)), // string ISO → Date
});
type ProdukMasuk = z.input<typeof SkemaProduk>; // dibuatPada: string
type Produk = z.output<typeof SkemaProduk>; // dibuatPada: Date
// Anggap ini respons server yang ternyata rusak
const responsServer: unknown = {
id: 1,
nama: "",
harga: "15000",
dibuatPada: "2026-10-24T08:00:00Z",
};
// Cara berbahaya: assertion. Tidak ada error compile, tapi data salah.
const palsu = responsServer as Produk;
console.log(typeof palsu.harga); // → string (padahal tipenya number!)
// Cara aman: safeParse
const hasil = SkemaProduk.safeParse(responsServer);
if (!hasil.success) {
console.log(z.prettifyError(hasil.error));
// → ✖ Nama produk wajib diisi
// → → at nama
// → ✖ Invalid input: expected number, received string
// → → at harga
} else {
const produk: Produk = hasil.data;
console.log(produk.dibuatPada.getFullYear());
}
// Data yang benar lolos dan sudah ditransformasi
const valid = SkemaProduk.parse({ id: 2, nama: "Teh", harga: 5000, dibuatPada: "2026-10-24T08:00:00Z" });
console.log(valid.dibuatPada instanceof Date, valid.dibuatPada.getUTCFullYear()); // → true 2026
const contohMasuk: ProdukMasuk = { id: 3, nama: "Kopi", harga: 8000, dibuatPada: "2026-10-24T08:00:00Z" };
console.log(SkemaProduk.parse(contohMasuk).nama); // → Kopi
Terakhir, memakai fungsi fetch tervalidasi di komponen React. Contoh di bawah memakai useEffect supaya mandiri; catatan di dalamnya menunjukkan versi TanStack Query yang lebih ringkas:
// DaftarTodo.tsx, data tervalidasi masuk ke komponen
import { useEffect, useState } from "react";
import { z } from "zod";
const SkemaTodo = z.object({ id: z.number(), title: z.string(), completed: z.boolean() });
type Todo = z.infer<typeof SkemaTodo>;
async function ambilTodos(): Promise<Todo[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=5");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return z.array(SkemaTodo).parse(await res.json());
}
// Dengan TanStack Query cukup: useQuery({ queryKey: ["todos"], queryFn: ambilTodos })
// dan 'data' otomatis bertipe Todo[] | undefined. Versi tanpa library:
export function DaftarTodo() {
const [todos, setTodos] = useState<Todo[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
ambilTodos()
.then(setTodos)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Gagal memuat"));
}, []);
if (error) return <p role="alert">Error: {error}</p>;
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.completed ? "✅" : "⬜"} {t.title}</li>
))}
</ul>
);
}
Kesalahan umum
- Menganggap
as Usersebagai validasi. Assertion hanya membungkam compiler. Kalau data salah, bug tetap terjadi, dan sekarang lebih sulit dilacak karena tipenya "terlihat benar". - Menulis tipe dan skema secara terpisah.
type Produk = {...}plusz.object({...})yang ditulis manual pasti suatu hari tidak sinkron. Turunkan tipe denganz.infer. - Memvalidasi di setiap komponen. Validasi sekali di pintu masuk (function fetch, loader, handler). Komponen menerima data yang sudah pasti benar.
- Memakai
parsetanpa menangani error.parsemelemparZodError. Bungkus dengantry/catch, pakaisafeParse, atau biarkan TanStack Query menangkapnya sebagaierror. - Lupa bahwa input dan output skema bisa berbeda. Dengan
transform,coerce, ataudefault, bentuk sebelum dan sesudah validasi berbeda. Pakaiz.inputuntuk data mentah (misalnya default values form) danz.output/z.inferuntuk hasilnya.
Latihan
- Mudah, Buat skema Zod
SkemaPenggunadenganid(number),nama(string minimal 2 karakter),email(z.email()), danaktif(boolean). Turunkan tipePenggunadenganz.infer. - Sedang, Tulis
function bacaPengaturan(): Pengaturanyang membaca JSON darilocalStoragedengan kunci"pengaturan", memvalidasi dengan skema{ tema: "terang" | "gelap", ukuranFont: number 12-24 }, dan mengembalikan nilai default kalau data tidak ada atau tidak valid. - Menantang, Buat skema respons berhalaman generik: function
skemaHalaman<T extends z.ZodType>(item: T)yang mengembalikanz.object({ items: z.array(item), halaman: z.number(), total: z.number() }). Pakai untuk memvalidasi daftar produk dan turunkan tipenya.
Lihat solusi
import { z } from "zod";
// 1. Mudah
const SkemaPengguna = z.object({
id: z.number(),
nama: z.string().min(2),
email: z.email(),
aktif: z.boolean(),
});
type Pengguna = z.infer<typeof SkemaPengguna>;
const p: Pengguna = SkemaPengguna.parse({ id: 1, nama: "Sari", email: "sari@contoh.id", aktif: true });
console.log(p.email); // → sari@contoh.id
// 2. Sedang
const SkemaPengaturan = z.object({
tema: z.enum(["terang", "gelap"]),
ukuranFont: z.number().int().min(12).max(24),
});
type Pengaturan = z.infer<typeof SkemaPengaturan>;
const DEFAULT_PENGATURAN: Pengaturan = { tema: "terang", ukuranFont: 16 };
function bacaPengaturan(): Pengaturan {
try {
const teks = localStorage.getItem("pengaturan");
if (teks === null) return DEFAULT_PENGATURAN;
const hasil = SkemaPengaturan.safeParse(JSON.parse(teks));
return hasil.success ? hasil.data : DEFAULT_PENGATURAN;
} catch {
return DEFAULT_PENGATURAN; // JSON rusak atau localStorage diblokir
}
}
console.log(typeof bacaPengaturan);
// 3. Menantang
function skemaHalaman<T extends z.ZodType>(item: T) {
return z.object({
items: z.array(item),
halaman: z.number().int().min(1),
total: z.number().int().nonnegative(),
});
}
const SkemaProduk = z.object({ id: z.number(), nama: z.string(), harga: z.number() });
const SkemaHalamanProduk = skemaHalaman(SkemaProduk);
type HalamanProduk = z.infer<typeof SkemaHalamanProduk>;
// { items: { id: number; nama: string; harga: number }[]; halaman: number; total: number }
const respons: HalamanProduk = SkemaHalamanProduk.parse({
items: [{ id: 1, nama: "Buku", harga: 45000 }],
halaman: 1,
total: 1,
});
console.log(respons.items[0]?.nama); // → Buku
Ringkasan
- TypeScript tidak bisa memeriksa data runtime;
res.json()bertipeanydanas Tipehanyalah janji. - Zod 4 memvalidasi data saat runtime dan
z.infer<typeof Skema>menurunkan tipenya, sehingga skema menjadi satu sumber kebenaran. parsemelempar error,safeParsemengembalikan discriminated unionsuccess: true | false;z.prettifyErrormembuat pesan error mudah dibaca.- Validasi sekali di batas aplikasi (fetch, form, localStorage, URL), lalu kirim data bertipe ke komponen.
- Dengan
transform/coerce, pakaiz.inputuntuk bentuk mentah danz.outputuntuk bentuk hasil.