Parse, jangan sekadar validasi berarti kita tidak berhenti di pertanyaan “apakah data ini valid?”, tetapi melangkah ke “bisakah data mentah ini diubah menjadi tipe domain yang aman dipakai?”. Untuk kode TypeScript, perbedaan ini sangat praktis: alih-alih menyebarkan if validasi di banyak tempat, kita membangun objek domain yang sudah lolos aturan sejak awal.
Hasilnya biasanya terasa langsung pada testing. Unit test jadi lebih fokus karena fungsi bisnis menerima input yang sudah benar bentuknya. Integration test tidak perlu menebak-nebak kombinasi data setengah valid. Contract test menjadi lebih jelas karena batas antara raw input dan domain data tegas. Dalam banyak kasus, ini juga mengurangi flaky test dan regresi yang muncul karena asumsi input tidak pernah benar-benar dikunci.
Inti gagasannya: validasi boolean hanya memberi jawaban ya/tidak, sedangkan parsing menghasilkan nilai baru yang lebih aman untuk dipakai di sisa sistem.
Mengapa validasi boolean sering berujung pada test yang rapuh
Anti-pattern yang umum di TypeScript adalah memisahkan “cek valid/tidak” dari “pemakaian data”. Misalnya:
type CreateUserInput = {
email?: string;
age?: number;
};
function isCreateUserInputValid(input: CreateUserInput): boolean {
return !!input.email && input.email.includes("@") && typeof input.age === "number" && input.age >= 18;
}
async function createUser(input: CreateUserInput) {
if (!isCreateUserInputValid(input)) {
throw new Error("invalid input");
}
// Setelah titik ini, TypeScript belum benar-benar tahu bahwa
// input.email dan input.age aman sebagai domain data.
return db.user.insert({
email: input.email!,
age: input.age!
});
}Sekilas ini terlihat cukup. Masalahnya:
- Informasi domain hilang. Setelah validasi, nilai tetap bertipe longgar seperti
stringatau bahkan opsional. - Assertion tersebar. Operator seperti
!, type assertion, atau pengecekan ulang muncul di banyak tempat. - Test jadi berulang. Banyak test hanya memverifikasi cabang “kalau invalid, lempar error” untuk setiap fungsi.
- Regresi mudah lolos. Saat aturan validasi berubah, pemakaian data lama bisa tetap lolos kompilasi tetapi gagal di runtime.
Dengan pola ini, kita cenderung menulis test yang mengamankan perilaku permukaan, bukan invariants domain. Akibatnya, test suite bisa besar tetapi tetap membiarkan bug lolos.
Prinsip parse, don’t validate di TypeScript
Bedakan raw input dan domain type
Langkah pertama adalah memisahkan tipe data eksternal dari tipe internal yang dipakai logika bisnis.
type RawCreateUserInput = {
email?: unknown;
age?: unknown;
};
type Email = string & { readonly __brand: "Email" };
type AdultAge = number & { readonly __brand: "AdultAge" };
type CreateUserCommand = {
email: Email;
age: AdultAge;
};RawCreateUserInput mewakili data dari HTTP request, form, queue, atau file. Sementara CreateUserCommand adalah bentuk yang sudah aman dipakai layanan bisnis.
Parsing menghasilkan nilai domain, bukan boolean
Alih-alih membuat fungsi isValid(...): boolean, buat parser yang mengembalikan hasil sukses atau error terstruktur.
type ParseSuccess<T> = { ok: true; value: T };
type ParseFailure = { ok: false; errors: string[] };
type ParseResult<T> = ParseSuccess<T> | ParseFailure;
function parseEmail(value: unknown): ParseResult<Email> {
if (typeof value !== "string") {
return { ok: false, errors: ["email harus berupa string"] };
}
const normalized = value.trim().toLowerCase();
if (!normalized.includes("@")) {
return { ok: false, errors: ["format email tidak valid"] };
}
return { ok: true, value: normalized as Email };
}
function parseAdultAge(value: unknown): ParseResult<AdultAge> {
if (typeof value !== "number" || !Number.isInteger(value)) {
return { ok: false, errors: ["age harus bilangan bulat"] };
}
if (value < 18) {
return { ok: false, errors: ["age minimal 18"] };
}
return { ok: true, value: value as AdultAge };
}
function parseCreateUserInput(input: RawCreateUserInput): ParseResult<CreateUserCommand> {
const email = parseEmail(input.email);
const age = parseAdultAge(input.age);
const errors = [
...(email.ok ? [] : email.errors),
...(age.ok ? [] : age.errors)
];
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
value: {
email: email.value,
age: age.value
}
};
}Keuntungan utamanya: setelah parsing berhasil, fungsi lain tidak lagi bergantung pada asumsi tersembunyi. Mereka menerima tipe yang merepresentasikan aturan bisnis secara lebih eksplisit.
Anti-pattern yang sering muncul
1. Validasi di controller, lalu data mentah diteruskan
async function createUserHandler(req: Request, res: Response) {
if (!isCreateUserInputValid(req.body)) {
res.status(400).json({ error: "invalid input" });
return;
}
await userService.createUser(req.body);
res.status(201).json({ ok: true });
}Masalahnya, userService.createUser masih menerima req.body mentah. Jika dipanggil dari jalur lain selain HTTP handler, validasi bisa terlewat.
2. Fungsi bisnis ikut memikul parsing berulang
async function createUser(input: any) {
if (typeof input.email !== "string") throw new Error("invalid email");
if (typeof input.age !== "number") throw new Error("invalid age");
if (input.age < 18) throw new Error("underage");
// logika bisnis
}Pola ini membuat setiap layanan menjadi campuran antara boundary validation dan domain behavior. Test juga harus terus meng-cover kasus input mentah di setiap fungsi.
3. Type predicate yang memberi rasa aman palsu
function isEmail(value: unknown): value is Email {
return typeof value === "string" && value.includes("@");
}Type predicate berguna, tetapi bisa menyesatkan bila ia hanya melakukan pengecekan ringan lalu mengklaim hasilnya sebagai tipe domain penuh. Jika domain type menuntut normalisasi, trimming, atau aturan tambahan, parser yang membentuk nilai baru biasanya lebih tepat daripada predicate murni.
Refactor nyata: dari layanan/API yang rapuh menjadi berbasis parsing
Sebelum refactor
type OrderPayload = {
userId?: string;
amount?: number;
currency?: string;
};
async function chargeOrder(payload: OrderPayload) {
if (!payload.userId) throw new Error("userId required");
if (typeof payload.amount !== "number" || payload.amount <= 0) {
throw new Error("amount invalid");
}
if (payload.currency !== "IDR" && payload.currency !== "USD") {
throw new Error("currency invalid");
}
return paymentGateway.charge({
userId: payload.userId,
amount: payload.amount,
currency: payload.currency
});
}Masalah pendekatan ini:
- Fungsi layanan menerima payload setengah terstruktur.
- Semua caller harus tahu format mentah yang sama.
- Test layanan bercampur antara test parsing dan test bisnis.
Sesudah refactor
type Currency = "IDR" | "USD";
type UserId = string & { readonly __brand: "UserId" };
type PositiveAmount = number & { readonly __brand: "PositiveAmount" };
type RawChargeOrderInput = {
userId?: unknown;
amount?: unknown;
currency?: unknown;
};
type ChargeOrderCommand = {
userId: UserId;
amount: PositiveAmount;
currency: Currency;
};
function parseUserId(value: unknown): ParseResult<UserId> {
if (typeof value !== "string" || value.trim() === "") {
return { ok: false, errors: ["userId wajib diisi"] };
}
return { ok: true, value: value.trim() as UserId };
}
function parsePositiveAmount(value: unknown): ParseResult<PositiveAmount> {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return { ok: false, errors: ["amount harus angka positif"] };
}
return { ok: true, value: value as PositiveAmount };
}
function parseCurrency(value: unknown): ParseResult<Currency> {
if (value === "IDR" || value === "USD") {
return { ok: true, value };
}
return { ok: false, errors: ["currency tidak didukung"] };
}
function parseChargeOrderInput(input: RawChargeOrderInput): ParseResult<ChargeOrderCommand> {
const userId = parseUserId(input.userId);
const amount = parsePositiveAmount(input.amount);
const currency = parseCurrency(input.currency);
const errors = [
...(userId.ok ? [] : userId.errors),
...(amount.ok ? [] : amount.errors),
...(currency.ok ? [] : currency.errors)
];
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
value: {
userId: userId.value,
amount: amount.value,
currency: currency.value
}
};
}
async function chargeOrder(command: ChargeOrderCommand) {
return paymentGateway.charge({
userId: command.userId,
amount: command.amount,
currency: command.currency
});
}
async function chargeOrderHandler(req: Request, res: Response) {
const parsed = parseChargeOrderInput(req.body);
if (!parsed.ok) {
res.status(400).json({ errors: parsed.errors });
return;
}
const result = await chargeOrder(parsed.value);
res.status(200).json(result);
}Perubahan pentingnya bukan sekadar “kode jadi rapi”, tetapi batas tanggung jawab menjadi jelas:
- Boundary layer menangani input mentah dan parsing.
- Service/domain layer menerima command yang sudah valid secara domain.
- Test bisa dipisah berdasarkan tanggung jawab tersebut.
Dampaknya pada strategi test
1. Unit test: fokus pada parser dan logika bisnis secara terpisah
Sebelum refactor, satu unit test sering harus menyiapkan data mentah lalu sekaligus memeriksa validasi dan perilaku bisnis. Sesudah refactor, pisahkan dua kelas test:
- Parser test: memverifikasi transformasi input mentah menjadi domain type atau error terstruktur.
- Service test: memverifikasi perilaku bisnis dengan asumsi input sudah valid.
describe("parseChargeOrderInput", () => {
it("mengubah payload valid menjadi command domain", () => {
const result = parseChargeOrderInput({
userId: "user-123",
amount: 150000,
currency: "IDR"
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.currency).toBe("IDR");
}
});
it("mengembalikan error terstruktur untuk payload invalid", () => {
const result = parseChargeOrderInput({
userId: "",
amount: -1,
currency: "EUR"
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual([
"userId wajib diisi",
"amount harus angka positif",
"currency tidak didukung"
]);
}
});
});
describe("chargeOrder", () => {
it("meneruskan command valid ke payment gateway", async () => {
const gateway = { charge: jest.fn().mockResolvedValue({ status: "paid" }) };
const command: ChargeOrderCommand = {
userId: "user-123" as UserId,
amount: 150000 as PositiveAmount,
currency: "IDR"
};
await gateway.charge(command);
expect(gateway.charge).toHaveBeenCalledWith(command);
});
});Keuntungan praktis:
- Test bisnis tidak lagi mengulang skenario invalid input yang sama.
- Fixture test lebih kecil dan stabil.
- Saat aturan parsing berubah, Anda cukup memperbarui parser test terkait.
2. Integration test: uji boundary sebenarnya, bukan detail internal
Untuk endpoint HTTP, integration test sebaiknya menegaskan bahwa payload mentah diparse dengan benar di boundary, lalu menghasilkan respons yang sesuai.
it("POST /orders/charge mengembalikan 400 untuk payload invalid", async () => {
const response = await request(app)
.post("/orders/charge")
.send({ userId: "", amount: 0, currency: "EUR" });
expect(response.status).toBe(400);
expect(response.body.errors).toContain("currency tidak didukung");
});Yang perlu dihindari: integration test yang terlalu tergantung pada implementasi detail parser, misalnya urutan semua pesan error jika urutan itu tidak penting secara kontrak. Semakin detail assertion terhadap hal non-esensial, semakin rapuh test Anda.
3. Contract test: tegas pada bentuk input-output lintas layanan
Jika sistem Anda berkomunikasi dengan layanan lain, parser adalah titik yang sangat cocok untuk mengikat kontrak data. Contract test berguna untuk memastikan:
- payload yang dikirim provider masih bisa diparse consumer,
- field wajib tetap ada,
- nilai enum atau bentuk nested object tidak diam-diam berubah.
Dalam pendekatan ini, contract test bukan sekadar “schema cocok”, tetapi “schema itu masih bisa diubah menjadi domain type yang dibutuhkan consumer”. Itu lebih dekat ke risiko bug nyata.
4. CI verification workflow: gagal lebih cepat di boundary
Dengan parsing yang eksplisit, workflow verifikasi di CI bisa dibuat berlapis:
- Type check untuk mendeteksi pemakaian tipe domain yang salah.
- Linting untuk menemukan assertion berlebihan atau pola
anyyang bocor. - Parser-focused unit test untuk semua boundary penting.
- Service unit test untuk logika bisnis murni.
- Integration/contract test untuk endpoint, event, atau message payload.
npm run typecheck
npm run lint
npm run test -- --runInBand
npm run test:integrationPerintahnya dapat berbeda di tiap proyek, tetapi prinsipnya sama: pastikan kegagalan parsing terdeteksi sedini mungkin sebelum regresi menyebar ke test level lebih tinggi yang lebih lambat dan lebih sulit di-debug.
Cara mengurangi test rapuh dengan pendekatan parsing
Gunakan fixture domain, bukan raw payload, untuk test bisnis
Untuk unit test service, lebih baik sediakan helper yang menghasilkan command valid daripada selalu membangun input mentah.
function makeChargeOrderCommand(
overrides: Partial<ChargeOrderCommand> = {}
): ChargeOrderCommand {
return {
userId: "user-123" as UserId,
amount: 100000 as PositiveAmount,
currency: "IDR",
...overrides
};
}Ini membuat test bisnis fokus pada variasi perilaku, bukan pada validitas dasar data.
Assert perilaku penting, bukan semua detail representasi
Jika parser mengembalikan daftar error, tentukan mana yang bagian dari kontrak publik. Bila urutan pesan tidak penting, jangan mengunci urutan itu di test. Bila normalisasi email penting, justru itu yang perlu diassert.
Pusatkan parsing di boundary yang jelas
Semakin banyak jalur masuk data, semakin tinggi risiko ada alur yang melewati parser. Buat konvensi sederhana:
- HTTP handler harus mem-parse request body/query/params.
- Consumer queue harus mem-parse message payload.
- Loader file atau cron job harus mem-parse data eksternal sebelum memanggil service.
Dengan begitu, service internal bisa diasumsikan menerima domain type, dan test pun menjadi lebih deterministik.
Kurangi mock pada area yang seharusnya dipastikan parser
Salah satu sumber test rapuh adalah mock yang terlalu bebas, misalnya mengirim objek yang tidak pernah mungkin lolos dari boundary nyata. Jika service menerima ChargeOrderCommand, jangan mock payload mentah berbentuk acak. Mock-lah kolaborator seperti repository atau gateway, bukan bentuk domain input yang sudah seharusnya dijaga parser.
Pilihan implementasi: manual parser vs schema library
Anda bisa menerapkan parse, don’t validate dengan parser manual seperti contoh di atas, atau menggunakan library schema/runtime validation. Apa pun alatnya, target desainnya tetap sama: hasilkan domain value yang aman, bukan sekadar boolean valid/tidak.
Kapan parser manual cocok
- Aturan domain relatif kecil dan spesifik.
- Anda ingin kontrol penuh atas normalisasi dan error.
- Domain type tidak selalu identik dengan shape payload eksternal.
Kapan schema library membantu
- Payload kompleks dan banyak nested field.
- Butuh deklarasi schema yang konsisten di banyak endpoint.
- Perlu integrasi dengan form, API docs, atau serialisasi.
Namun, ada jebakan umum: memakai library hanya untuk menghasilkan success: boolean, lalu tetap meneruskan data mentah ke service. Itu baru memindahkan validasi, belum benar-benar menerapkan parsing ke domain model.
Checklist review untuk pull request
- Apakah input eksternal dibedakan jelas dari domain type internal?
- Apakah fungsi boundary mengembalikan hasil parse yang eksplisit, bukan hanya boolean?
- Apakah service menerima command/domain object yang sudah aman?
- Apakah ada
as,!, atauanyyang dipakai untuk menembus invariants? - Apakah test parser dan test bisnis dipisah?
- Apakah integration test memeriksa kontrak boundary, bukan detail implementasi internal?
- Apakah error parsing cukup informatif untuk debugging tanpa membocorkan detail sensitif?
- Apakah semua jalur masuk data—HTTP, queue, cron, file—sudah melewati parser?
Kapan pendekatan ini tidak cocok atau perlu dibatasi
1. Script kecil sekali pakai
Untuk skrip pendek dengan input sangat terbatas, membangun domain parser lengkap bisa terasa berlebihan. Tetap jaga boundary dengan pengecekan dasar, tetapi jangan memaksa semua pola arsitektur besar.
2. Domain rule sangat cair dan belum stabil
Jika aturan bisnis masih berubah harian, branding type dan parser berlapis bisa memperlambat iterasi. Dalam kondisi ini, mulai dari parser sederhana lalu kencangkan seiring stabilnya domain sering lebih realistis.
3. Data memang harus tetap longgar
Ada kasus seperti logging pipeline, analytics event mentah, atau proxy service yang tugasnya meneruskan data. Di sini, pemaksaan domain model ketat di semua titik tidak selalu bernilai. Fokuskan parsing pada area yang benar-benar memakai data untuk keputusan bisnis.
Debugging tips saat mulai menerapkan parse, don’t validate
- Lacak sumber data mentah. Banyak bug terjadi karena parser hanya dipasang di satu endpoint, tetapi jalur lain masih memanggil service langsung.
- Catat hasil parse gagal di boundary. Simpan konteks yang relevan untuk observabilitas, tetapi hindari menulis data sensitif mentah ke log.
- Periksa normalisasi. Masalah seperti spasi, huruf besar-kecil, angka desimal, atau zona waktu sering lolos dari validasi sederhana tetapi tertangkap saat parsing dibuat eksplisit.
- Audit test yang terlalu banyak menyentuh invalid input di service layer. Itu biasanya sinyal bahwa parsing belum dipusatkan dengan baik.
Penutup
Parse, jangan sekadar validasi adalah strategi desain yang langsung berdampak pada kualitas test TypeScript. Dengan memisahkan raw input dari domain type, Anda mengurangi asumsi tersembunyi, menahan bug di boundary, dan membuat test lebih fokus serta lebih tahan terhadap perubahan internal.
Jika ingin mulai tanpa refactor besar, pilih satu endpoint atau satu layanan yang paling sering bermasalah oleh input. Buat parser yang mengubah payload mentah menjadi command domain, lalu ubah service agar hanya menerima command itu. Dari sana, biasanya pola testing yang lebih sederhana dan lebih stabil akan mengikuti dengan sendirinya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!