Validasi Data End-to-End dengan Zod: Menjaga Konsistensi Tipe dari Frontend hingga Backend
1. Pendahuluan
Pernahkah Anda mengalami bug aneh di mana data yang dikirim dari frontend tidak sesuai dengan yang diharapkan di backend? Atau sebaliknya, data dari API backend tidak cocok dengan ekspektasi tipe di frontend, menyebabkan error runtime yang sulit dilacak? Jika ya, Anda tidak sendirian. Ini adalah masalah umum di pengembangan web modern, terutama di aplikasi yang kompleks.
Inkonsistensi data dan tipe antara frontend dan backend bisa menjadi sumber frustrasi besar bagi developer. Bayangkan Anda mengubah struktur data di backend, tapi lupa memperbarui validasi di frontend. Atau, seorang developer frontend menambahkan field baru tanpa mengkomunikasikannya ke backend. Hasilnya? Bug, data yang rusak, dan waktu debugging yang terbuang sia-sia.
Di sinilah peran validasi data end-to-end menjadi sangat krusial. Tujuannya adalah memastikan bahwa data yang mengalir di seluruh stack aplikasi Anda (dari input pengguna di UI, melalui API, hingga disimpan di database) selalu konsisten dan mematuhi aturan yang sama. Dengan pendekatan ini, kita bisa menangkap sebagian besar error terkait tipe dan validasi sedini mungkin, bahkan sebelum kode berjalan di produksi.
Artikel ini akan membahas bagaimana kita bisa mencapai validasi data end-to-end yang kuat dan type-safe menggunakan Zod, sebuah library schema validation yang populer di ekosistem JavaScript/TypeScript. Zod memungkinkan kita mendefinisikan skema data sebagai “single source of truth” yang bisa dibagikan dan digunakan di seluruh bagian aplikasi Anda.
2. Zod sebagai Solusi Universal untuk Validasi
Ada banyak library validasi di luar sana, tapi Zod menonjol karena beberapa alasan utama:
- Type-Safe by Design: Ini adalah fitur paling powerful dari Zod. Saat Anda mendefinisikan skema Zod, Zod secara otomatis menginferensikan tipe TypeScript yang sesuai. Ini berarti skema Anda tidak hanya memvalidasi data saat runtime, tetapi juga memberikan jaminan tipe yang kuat saat compile-time.
- Developer Experience (DX) yang Hebat: Sintaks Zod yang intuitif dan ekspresif membuatnya mudah dibaca dan ditulis.
- Fleksibel dan Ekstensibel: Zod bisa menangani berbagai jenis data, dari string, number, object, array, hingga custom validation yang kompleks.
- Zero Dependency: Zod ringan dan tidak memiliki dependensi eksternal, menjadikannya pilihan yang efisien.
- Performa: Zod dirancang untuk performa yang baik, penting untuk aplikasi skala besar.
Karena sifatnya yang type-safe dan fleksibel, Zod sangat ideal untuk digunakan di seluruh stack: dari form di React, validasi request di Express/NestJS, hingga skema data di ORM.
3. Membangun Skema Data Bersama (Shared Schemas)
Kunci dari validasi end-to-end adalah memiliki skema data yang dibagikan antara frontend dan backend. Idealnya, skema ini berada di lokasi yang bisa diakses oleh kedua sisi, misalnya di dalam sebuah monorepo, atau di package terpisah yang di-publish.
Mari kita mulai dengan contoh skema data untuk membuat atau memperbarui profil pengguna.
// src/common/schemas/user-schema.ts (atau di package shared-types)
import { z } from 'zod';
export const userProfileSchema = z.object({
id: z.string().uuid().optional(), // ID opsional untuk update
name: z.string().min(3, "Nama minimal 3 karakter").max(50, "Nama maksimal 50 karakter"),
email: z.string().email("Format email tidak valid"),
age: z.number().int("Umur harus bilangan bulat").min(18, "Anda harus berusia minimal 18 tahun").max(100, "Umur maksimal 100 tahun").optional(),
bio: z.string().max(200, "Bio maksimal 200 karakter").optional(),
hobbies: z.array(z.string()).optional(),
});
// Inferensikan tipe TypeScript dari skema Zod
export type UserProfile = z.infer<typeof userProfileSchema>;
// Skema untuk membuat pengguna baru (tanpa ID)
export const newUserProfileSchema = userProfileSchema.omit({ id: true });
export type NewUserProfile = z.infer<typeof newUserProfileSchema>;
// Skema untuk update sebagian data (parsial)
export const partialUserProfileSchema = userProfileSchema.partial();
export type PartialUserProfile = z.infer<typeof partialUserProfileSchema>;
📌 Penting: Dengan z.infer<typeof userProfileSchema>, kita mendapatkan tipe TypeScript yang persis sama dengan aturan validasi yang kita definisikan. Ini adalah fondasi dari type-safety end-to-end kita.
4. Validasi di Sisi Frontend: Mengamankan Input Pengguna
Di frontend, kita akan menggunakan userProfileSchema untuk memvalidasi input dari form dan juga data yang diterima dari API. Ini memastikan bahwa input pengguna sudah bersih dan data dari backend sesuai ekspektasi sebelum digunakan di UI.
Contoh 1: Validasi Form dengan React Hook Form + Zod
React Hook Form (atau library form lainnya seperti Formik) memiliki integrasi yang sangat baik dengan Zod melalui resolver.
// src/frontend/components/UserProfileForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// Import skema yang dibagikan
import { userProfileSchema, UserProfile } from '../../common/schemas/user-schema';
interface UserProfileFormProps {
initialData?: UserProfile;
onSubmit: (data: UserProfile) => void;
}
function UserProfileForm({ initialData, onSubmit }: UserProfileFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserProfile>({
resolver: zodResolver(userProfileSchema), // ✨ Integrasi Zod di sini!
defaultValues: initialData,
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Nama</label>
<input
id="name"
type="text"
{...register('name')}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
type="email"
{...register('email')}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="age" className="block text-sm font-medium text-gray-700">Umur</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })} // Penting untuk number input
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.age && <p className="text-red-500 text-xs mt-1">{errors.age.message}</p>}
</div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Simpan Profil
</button>
</form>
);
}
export default UserProfileForm;
Di sini, zodResolver secara otomatis akan mengambil aturan validasi dari userProfileSchema dan menerapkannya ke form Anda. Ini sangat mengurangi boilerplate dan memastikan bahwa aturan validasi form Anda sama persis dengan yang ada di backend.
Contoh 2: Validasi Data dari API Response
Meskipun backend sudah memvalidasi data sebelum mengirimkannya, ada baiknya juga memvalidasi data yang diterima di frontend. Ini berfungsi sebagai lapisan keamanan tambahan dan membantu menangkap inkonsistensi jika ada perubahan API yang tidak terduga atau kesalahan di backend.
// src/frontend/services/userService.ts
import { userProfileSchema, UserProfile } from '../../common/schemas/user-schema';
async function fetchUserProfile(userId: string): Promise<UserProfile> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Gagal mengambil profil pengguna');
}
const rawData = await response.json();
// ✨ Validasi data yang diterima dari API
const parsedData = userProfileSchema.parse(rawData);
return parsedData;
}
// Contoh penggunaan:
async function loadUser(userId: string) {
try {
const user = await fetchUserProfile(userId);
console.log('Profil pengguna berhasil dimuat:', user);
// Sekarang Anda yakin `user` memiliki tipe UserProfile yang valid
} catch (error) {
console.error('Error saat memuat profil pengguna:', error);
// Tangani error validasi atau error API lainnya
}
}
Jika rawData tidak sesuai dengan userProfileSchema, userProfileSchema.parse(rawData) akan melempar error, mencegah data yang tidak valid merusak UI atau logika aplikasi Anda.
5. Validasi di Sisi Backend: Mengamankan API Anda
Di sisi backend, validasi adalah benteng pertama untuk melindungi integritas data Anda. Kita akan menggunakan skema Zod yang sama untuk memvalidasi request body atau query parameters yang masuk ke API.
Contoh: Validasi Request Body dengan Express
// src/backend/routes/user-routes.ts
import express, { Request, Response, NextFunction } from 'express';
// Import skema yang dibagikan
import { newUserProfileSchema, partialUserProfileSchema, userProfileSchema } from '../../common/schemas/user-schema';
const router = express.Router();
// Middleware validasi umum
const validate = (schema: any) => (req: Request, res: Response, next: NextFunction) => {
try {
// ✨ Validasi request body menggunakan skema Zod
req.body = schema.parse(req.body);
next();
} catch (error: any) {
// Tangani error validasi Zod
console.error('Validasi gagal:', error.errors);
res.status(400).json({
message: 'Data request tidak valid',
errors: error.errors, // Zod menyediakan detail error yang bagus
});
}
};
// Endpoint untuk membuat pengguna baru
router.post('/users', validate(newUserProfileSchema), (req: Request, res: Response) => {
// Jika sampai sini, req.body sudah divalidasi dan memiliki tipe NewUserProfile
const newUser = req.body; // Tipe inferensi otomatis!
console.log('Membuat pengguna baru:', newUser);
// Lanjutkan ke logika penyimpanan ke database
res.status(201).json({ message: 'Pengguna berhasil dibuat', user: newUser });
});
// Endpoint untuk memperbarui profil pengguna
router.put('/users/:id', validate(userProfileSchema), (req: