Setiap kali API Anda menerima request, Anda memvalidasinya. Setiap kali fungsi menerima argumen dari sistem eksternal, Anda memeriksanya. Lakukan ini secara manual, dan satu endpoint bisa mengakumulasi lebih banyak kode validasi daripada logika bisnis.

Ini adalah pajak tersembunyi dari runtime contracts. Anda membutuhkannya karena type system berbohong: JSON melalui HTTP, field database, dan environment variable semuanya tiba sebagai data untyped pada runtime. Namun mengimplementasikannya dengan statement if yang ditulis manual mengubah fungsi sederhana menjadi tembok kode defensif yang tidak terbaca.

Ada cara yang lebih baik.

Apa sebenarnya runtime contracts itu

Runtime contract adalah jaminan yang diberikan fungsi terhadap input dan output-nya. Jika Anda memasukkan data yang valid, Anda mendapatkan data yang valid keluar. Jika Anda melanggar contract, fungsi tersebut akan gagal dengan cepat disertai error yang jelas.

Ini adalah Design by Contract, dan konsep ini sudah ada sejak Eiffel. Idénnya bukan hal baru. Yang baru adalah tooling yang memungkinkan Anda mengekspresikan contracts tanpa menulis validator untuk setiap field.

Wawasan intinya: contracts adalah schema, bukan kode. Schema menggambarkan bentuk, constraint, dan relasi. Kode yang memeriksa hal-hal tersebut secara manual hanyalah schema yang ditulis dalam bahasa yang salah.

Jebakan boilerplate

Inilah penampakan validasi manual dalam praktiknya. Anda memiliki fungsi yang memproses request signup pengguna:

function createUser(payload: unknown) {
  if (!payload || typeof payload !== "object") {
    throw new Error("Invalid payload");
  }
  if (!("email" in payload) || typeof (payload as any).email !== "string") {
    throw new Error("email is required and must be a string");
  }
  if (!(payload as any).email.includes("@")) {
    throw new Error("email must be valid");
  }
  if (!("age" in payload) || typeof (payload as any).age !== "number") {
    throw new Error("age is required and must be a number");
  }
  if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
    throw new Error("age must be an integer of at least 13");
  }
  if (
    "tags" in payload &&
    (!Array.isArray((payload as any).tags) ||
      !(payload as any).tags.every((t: any) => typeof t === "string"))
  ) {
    throw new Error("tags must be an array of strings");
  }
  // ... finally, the actual work
}

Ini melelahkan, rentan error, dan tidak lengkap. Apakah Anda memeriksa bahwa email cocok dengan format email yang sebenarnya? Bahwa age bukan NaN? Bahwa elemen tags adalah string yang tidak kosong? Mungkin tidak, karena Anda sudah lelah.

Dan ini hanya satu fungsi. Kalikan dengan setiap boundary di sistem Anda.

Bagaimana skema deklaratif menggantikan boilerplate

Alih-alih menulis validator, deskripsikan bentuknya dan biarkan library yang menangani enforce-nya.

import { z } from "zod";

const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13),
  tags: z.array(z.string().min(1)).optional(),
});

function createUser(payload: unknown) {
  const user = UserSchema.parse(payload);
  // user is typed as { email: string; age: number; tags?: string[] }
  // actual business logic goes here
}

Schema tersebut adalah dokumentasi. Ia juga sekaligus validator, definisi type, dan generator pesan error. Ubah schema di satu tempat, dan contract akan terupdate di mana pun ia digunakan.

Ini bukan sihir. Library menelusuri schema pada runtime dan menerapkan pemeriksaannya. Perbedaannya adalah pemeriksaan tersebut disusun dari primitive kecil yang reusable, bukan ditulis manual setiap kali.

Contracts pada function boundaries

Kekuatan sebenarnya datang dari menempelkan schema langsung ke input dan output fungsi. Anda ingin contract berada di boundary, bukan merembes ke dalam implementasi.

function withContract<TInput, TOutput>(
  inputSchema: z.ZodSchema<TInput>,
  outputSchema: z.ZodSchema<TOutput>,
  handler: (input: TInput) => TOutput
) {
  return (rawInput: unknown): TOutput => {
    const input = inputSchema.parse(rawInput);
    const output = handler(input);
    return outputSchema.parse(output);
  };
}

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

const createUser = withContract(
  UserSchema,
  UserResponseSchema,
  (user) => {
    // zero validation code here
    return db.users.create(user);
  }
);

Sekarang body fungsi tidak mengandung kode validasi sama sekali. Contract di-enforce di edge, dan compiler masih mengetahui type di dalamnya karena schema melakukan infer terhadapnya.

Di mana ini bermasalah: performance, inference, dan over-validation

Skema deklaratif tidak gratis. Anda harus mengetahui trade-off sebelum mengadopsinya.

Performance adalah kekhawatiran yang paling jelas. Parsing schema yang kompleks lebih lambat daripada beberapa pemeriksaan if. Dalam praktiknya, perbedaannya tidak signifikan untuk sebagian besar API. Zod kira-kira 10× lebih lambat daripada pemeriksaan manual untuk objek sederhana. Untuk sebagian besar handler HTTP, perbedaannya hanya microsecond. Jika Anda memvalidasi jutaan record dalam hot loop, lakukan benchmark terlebih dahulu.

Celah type inference juga bisa menyulitkan Anda. Type yang diinfer dari schema terkadang lebih strict atau lebih longgar daripada yang Anda tulis manual. Anda mungkin perlu mendefinisikan custom type dan memberitahu schema untuk mencocokkannya, bukan sebaliknya.

Over-validation adalah pembunuh diam-diam. Tidak setiap fungsi membutuhkan runtime contract. Fungsi internal yang menerima data yang sudah divalidasi di API boundary tidak perlu diperiksa ulang. Terapkan contracts di system boundaries, bukan di setiap helper function.

Cara mengimplementasikan ini tanpa melakukan rewrite codebase Anda

Anda tidak membutuhkan migration big-bang. Mulailah dari entry point API Anda.

  1. Pilih satu fungsi yang menghadap ke eksternal atau route handler.
  2. Tulis schema untuk input-nya.
  3. Ganti validasi manual dengan schema parsing.
  4. Biarkan schema melakukan infer TypeScript type. Hapus interface yang Anda tulis manual.

Lakukan ini untuk satu endpoint, lalu satu service, lalu satu domain. Dalam seminggu, Anda akan menyadari test suite Anda menyusut karena Anda tidak lagi perlu menguji edge case validasi yang sudah dicakup oleh schema.

Jika Anda tidak menggunakan TypeScript, pattern ini tetap bisa diterapkan. Python memiliki Pydantic. Go memiliki go-playground/validator. Rust memiliki validator. Prinsipnya sama: deskripsikan contract, hasilkan pemeriksaannya.

FAQ

Apakah runtime contracts menggantikan unit test?

Tidak. Ia menggantikan subset unit test yang memverifikasi validasi input. Anda masih menguji logika bisnis, penanganan error, dan perilaku integrasi.

Bagaimana dengan custom validation rules?

Setiap library schema yang serius mendukung custom refinement. Gunakanlah dengan hemat. Jika custom rule Anda lebih panjang dari lima baris, kemungkinan besar ia seharusnya berada di body fungsi, bukan di schema.

Apakah saya harus memvalidasi pada saat keluar juga?

Ya, untuk fungsi-fungsi kritis. Output contract menangkap bug di mana logika bisnis Anda menghasilkan data yang malformed. Lebih murah untuk gagal di sumbernya daripada membiarkan data buruk merambat ke service lain.

Kesimpulan akhir

Runtime contracts tidak bisa dinegosiasikan untuk sistem yang menangani data untyped. Satu-satunya pilihan adalah apakah Anda membayarnya dengan boilerplate atau dengan library schema.

Library schema membebani Anda dengan dependency dan overhead performance yang kecil. Boilerplate membebani Anda dengan maintainability, correctness, dan kewarasan Anda.

Pilih dependency-nya.