Jika kontrak API Anda hanya divalidasi lalu data mentah tetap diteruskan ke dalam aplikasi, Anda masih menyisakan banyak ruang untuk bug. Prinsip parse, don't validate mendorong pendekatan yang lebih aman: input eksternal tidak cukup dinyatakan “valid”, tetapi harus diubah menjadi tipe domain yang sudah tepercaya sebelum dipakai oleh logika bisnis.
Dalam konteks integrasi backend di TypeScript, pendekatan ini sangat berguna untuk request body, query params, header autentikasi, payload webhook, idempotency key, sampai respons dari service pihak ketiga. Hasilnya bukan cuma type safety yang lebih kuat, tetapi juga keputusan error handling yang lebih jelas: mana yang harus dibalas 400, 401, 422, mana yang aman untuk retry, dan mana yang perlu diberi sinyal observability lebih tinggi.
Apa maksudnya “parse, jangan validasi”?
Pendekatan validasi tradisional biasanya seperti ini:
- Menerima data mentah dari luar.
- Menjalankan fungsi
isValid(...)yang mengembalikan boolean. - Jika
true, data yang sama diteruskan ke seluruh aplikasi.
Masalahnya, data yang lolos validasi sering tetap bertipe terlalu umum, misalnya unknown, any, atau objek dengan properti opsional yang longgar. Akibatnya, kode setelahnya masih harus penuh dengan pengecekan tambahan, type assertion, atau asumsi diam-diam.
Pada pendekatan parse, don't validate, alurnya berubah:
- Terima data mentah dari luar sebagai
unknown. - Parse data tersebut menggunakan schema atau parser.
- Jika parsing berhasil, hasilnya adalah objek bertipe domain yang aman dipakai.
- Jika parsing gagal, hentikan alur sedini mungkin dengan error yang terstruktur.
Dengan kata lain, parser tidak hanya menjawab “apakah data ini valid?”, tetapi juga “jika valid, bentuk aman persisnya seperti apa?”.
Anti-pattern: validasi boolean yang tetap menyebarkan data mentah
Berikut contoh pola yang sering ditemui di handler backend:
type CreateOrderBody = {
customerId?: string;
amount?: number;
currency?: string;
};
function isCreateOrderBody(value: any): boolean {
return (
value &&
typeof value.customerId === 'string' &&
typeof value.amount === 'number' &&
typeof value.currency === 'string'
);
}
async function handleCreateOrder(req: { body: unknown }) {
if (!isCreateOrderBody(req.body)) {
return {
status: 400,
body: { error: 'invalid_request_body' }
};
}
// req.body tetap berasal dari input mentah
const body = req.body as CreateOrderBody;
// Masih mudah bocor ke asumsi lain di bawah ini
const normalizedCurrency = body.currency!.toUpperCase();
return createOrder({
customerId: body.customerId!,
amount: body.amount!,
currency: normalizedCurrency
});
}Sekilas ini terlihat aman, tetapi ada beberapa masalah nyata:
- Data mentah masih lolos masuk. Anda hanya memeriksa bentuk dasar, tetapi tidak benar-benar membangun objek domain yang siap dipakai.
- Normalisasi sering tercecer. Misalnya
currencyperlu diubah ke huruf besar,customerIdperlu di-trim, atauamountperlu dicek lebih dari sekadar bertipe number. - Type assertion menjadi kebiasaan. Begitu ada
asatau!, jaminan tipe mulai melemah. - Error sulit dibedakan. Semua kegagalan cenderung jatuh ke 400 generik, padahal ada perbedaan antara malformed request, unauthorized, dan business rule violation.
Refactor ke parser: ubah input mentah menjadi tipe domain aman
Anda bisa menerapkan prinsip ini dengan schema library seperti Zod, Valibot, io-ts, atau parser buatan sendiri. Pilih alat yang sesuai tim Anda, tetapi prinsipnya tetap sama: hasil parsing adalah objek bertipe domain, bukan sekadar boolean.
Contoh dengan schema parser:
import { z } from 'zod';
const Currency = z.enum(['IDR', 'USD', 'SGD']);
const CreateOrderInputSchema = z.object({
customerId: z.string().trim().min(1),
amount: z.number().positive(),
currency: z.string().trim().transform((v) => v.toUpperCase()).pipe(Currency)
});
type CreateOrderInput = z.infer<typeof CreateOrderInputSchema>;
function parseCreateOrderInput(raw: unknown): CreateOrderInput {
return CreateOrderInputSchema.parse(raw);
}
async function handleCreateOrder(req: { body: unknown }) {
let input: CreateOrderInput;
try {
input = parseCreateOrderInput(req.body);
} catch (err) {
return {
status: 400,
body: {
error: 'invalid_request_body'
}
};
}
return createOrder(input);
}Perbedaan pentingnya ada di sini:
req.bodydiperlakukan sebagaiunknown.- Parser bertugas sekaligus memeriksa, menormalisasi, dan menghasilkan tipe aman.
- Setelah parsing berhasil, fungsi bisnis tidak perlu lagi curiga terhadap bentuk data.
Ini memindahkan beban ketidakpastian ke batas sistem, yaitu tempat yang memang seharusnya menangani data tidak tepercaya.
Mendesain tipe domain, bukan hanya schema input
Salah satu manfaat terbesar dari pendekatan ini adalah Anda terdorong untuk membedakan antara transport type dan domain type.
Contoh buruknya adalah langsung memakai objek HTTP sebagai model internal. Contoh yang lebih baik:
type CustomerId = string & { readonly brand: 'CustomerId' };
type IdempotencyKey = string & { readonly brand: 'IdempotencyKey' };
type Money = {
amount: number;
currency: 'IDR' | 'USD' | 'SGD';
};
type CreateOrderCommand = {
customerId: CustomerId;
money: Money;
idempotencyKey: IdempotencyKey;
};Di sini parser tidak berhenti pada “body punya field yang benar”, tetapi membangun objek internal yang mencerminkan aturan domain. Ini mengurangi peluang tertukarnya string biasa dengan identifier atau token yang maknanya berbeda.
Kapan branded type berguna?
Branded type berguna ketika banyak nilai sama-sama berupa string, tetapi semantiknya berbeda: CustomerId, OrderId, WebhookSignature, dan IdempotencyKey. TypeScript tidak memberi validasi runtime untuk ini, jadi branding perlu digabung dengan parser di runtime agar tidak hanya aman di level kompilasi.
Penerapan praktis pada kontrak API
1. Request body
Request body biasanya paling jelas: input JSON dari klien atau service lain harus diparse sebelum masuk ke use case.
import { z } from 'zod';
const CreatePaymentBodySchema = z.object({
orderId: z.string().trim().min(1),
amount: z.number().positive(),
currency: z.string().trim().transform((v) => v.toUpperCase()),
paymentMethod: z.enum(['card', 'bank_transfer'])
});
type CreatePaymentBody = z.infer<typeof CreatePaymentBodySchema>;Gunakan schema ini di level HTTP adapter. Jangan kirim req.body mentah ke service layer.
2. Query params
Query params sering diabaikan karena terlihat sederhana, padahal semua nilainya biasanya datang sebagai string. Di sinilah parser berguna untuk coercion dan batasan nilai.
const ListPaymentsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['pending', 'paid', 'failed']).optional()
});
type ListPaymentsQuery = z.infer<typeof ListPaymentsQuerySchema>;Tanpa parsing, bug yang sering muncul adalah perbandingan string vs number, offset salah hitung, atau pageSize terlalu besar sehingga membebani database.
3. Header auth
Header autentikasi sebaiknya tidak diperiksa dengan if (!req.headers.authorization) saja. Bentuk dan skemanya juga perlu diparse.
const AuthHeaderSchema = z.string().regex(/^Bearer\s+.+$/);
type AccessToken = string & { readonly brand: 'AccessToken' };
function parseBearerToken(raw: unknown): AccessToken {
const header = AuthHeaderSchema.parse(raw);
const token = header.replace(/^Bearer\s+/, '').trim();
if (!token) {
throw new Error('empty_bearer_token');
}
return token as AccessToken;
}Dengan ini, ada pemisahan jelas antara:
- 401 Unauthorized: header auth tidak ada, format salah, token tidak valid.
- 400 Bad Request: request secara umum malformed, misalnya body JSON rusak.
Perlu dicatat, keputusan 400 vs 401 bisa sedikit berbeda antar sistem, tetapi pola umumnya tetap: error autentikasi jangan dicampur dengan error body yang malformed.
4. Webhook payload
Webhook adalah area yang sangat cocok untuk parse, don't validate karena datanya berasal dari luar sistem Anda dan sering memiliki variasi event. Selain body, Anda biasanya juga perlu memparse header signature, event type, dan timestamp.
const WebhookHeadersSchema = z.object({
'x-signature': z.string().min(1),
'x-event-type': z.string().min(1),
'x-request-id': z.string().min(1).optional()
});
const PaymentSucceededSchema = z.object({
type: z.literal('payment.succeeded'),
data: z.object({
paymentId: z.string().min(1),
orderId: z.string().min(1),
amount: z.number().positive()
})
});
const PaymentFailedSchema = z.object({
type: z.literal('payment.failed'),
data: z.object({
paymentId: z.string().min(1),
reason: z.string().min(1)
})
});
const WebhookEventSchema = z.discriminatedUnion('type', [
PaymentSucceededSchema,
PaymentFailedSchema
]);Keuntungan discriminated union di sini besar: setelah event berhasil diparse, TypeScript tahu bentuk data berdasarkan type. Anda tidak perlu lagi rangkaian if yang rawan salah properti.
5. Idempotency key
Pada endpoint yang bisa di-retry oleh klien atau gateway, idempotency key sebaiknya diperlakukan sebagai kontrak formal, bukan string opsional yang dicek sambil lalu.
const IdempotencyKeySchema = z.string().trim().min(1).max(255);
type ParsedHeaders = {
idempotencyKey: string;
};
function parseIdempotencyKey(raw: unknown): string {
return IdempotencyKeySchema.parse(raw);
}Jika key wajib untuk operasi tertentu, kegagalannya biasanya lebih tepat dianggap 400 karena klien tidak memenuhi kontrak request. Jika key ada tetapi formatnya tidak diterima, tetap tangani sebagai input error, bukan error internal.
6. Respons dari service pihak ketiga
Ini bagian yang paling sering dilupakan. Banyak tim disiplin memvalidasi request masuk, tetapi langsung percaya pada respons upstream. Padahal integrasi pihak ketiga bisa berubah, mengembalikan field kosong, tipe data berbeda, atau status tidak konsisten.
const ProviderChargeResponseSchema = z.object({
id: z.string().min(1),
status: z.enum(['pending', 'succeeded', 'failed']),
amount: z.number().nonnegative(),
currency: z.string().trim().transform((v) => v.toUpperCase()),
createdAt: z.string().min(1)
});
type ProviderChargeResponse = z.infer<typeof ProviderChargeResponseSchema>;
async function createChargeWithProvider(payload: unknown): Promise<ProviderChargeResponse> {
const response = await fetch('https://provider.example/charges', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'content-type': 'application/json' }
});
const json: unknown = await response.json();
return ProviderChargeResponseSchema.parse(json);
}Dengan cara ini, error dari provider bisa dipisahkan menjadi:
- HTTP gagal atau timeout.
- Respons provider sukses secara HTTP, tetapi bentuk payload tidak sesuai kontrak yang Anda harapkan.
- Respons valid, tetapi status bisnisnya
failed.
Pemisahan ini penting untuk retry, alarm, dan analisis insiden.
Error handling: 400, 401, 422 tanpa campur aduk
Jika Anda memparse kontrak API dengan disiplin, pemetaan status code menjadi lebih jelas.
400 Bad Request
Gunakan ketika request tidak bisa diproses karena bentuk input tidak sesuai kontrak transport:
- JSON malformed.
- Field wajib hilang.
- Tipe salah, misalnya
amountberupa string non-numeric. - Header atau query param tidak sesuai format dasar.
401 Unauthorized
Gunakan ketika masalah ada pada autentikasi:
- Header
Authorizationhilang. - Format bearer token salah.
- Token tidak valid atau gagal diverifikasi.
Jangan menutupi kegagalan autentikasi sebagai 400 umum jika Anda ingin perilaku klien dan observability lebih konsisten.
422 Unprocessable Entity
Gunakan ketika bentuk request sudah benar, tetapi aturan domain atau bisnis tidak terpenuhi:
- Mata uang didukung kontrak transport, tetapi tidak diizinkan untuk merchant tertentu.
- Order sudah dibayar.
- Idempotency key dipakai ulang dengan payload berbeda.
- Webhook event valid secara struktur, tetapi mengacu ke resource yang tidak relevan dengan state sistem saat ini.
Prinsip praktisnya:
400 untuk gagal parse kontrak input, 401 untuk gagal autentikasi, 422 untuk input yang sudah ter-parse tetapi ditolak aturan domain.
Dampak ke retry, idempotency, dan integrasi backend
Retry jadi lebih aman
Dalam integrasi backend, retry seharusnya hanya dilakukan untuk kegagalan yang mungkin pulih, misalnya timeout, 502, atau gangguan jaringan. Jika Anda tidak memisahkan parse error dari transient error, sistem bisa membuang sumber daya untuk me-retry request yang sejak awal mustahil berhasil.
Contohnya:
- Jangan retry jika webhook payload gagal parse karena field wajib hilang.
- Jangan retry jika query param salah format.
- Boleh pertimbangkan retry jika respons provider timeout atau 503.
- Retry dengan hati-hati jika provider merespons format tak terduga; ini sering menandakan kontrak berubah dan perlu intervensi, bukan sekadar retry buta.
Idempotency lebih mudah ditegakkan
Jika idempotency key diparse lebih dulu dan dibawa sebagai bagian dari command domain, Anda bisa memastikan semua jalur eksekusi bergantung pada nilai yang sudah bersih dan konsisten. Ini penting untuk mencegah variasi seperti spasi tersembunyi, casing yang tidak diharapkan, atau key kosong yang lolos karena hanya diperiksa seadanya.
Boundary antar layer jadi tegas
Pattern yang sehat biasanya seperti ini:
- Transport layer: terima
unknowndari HTTP, queue, webhook, atau provider. - Parser/schema layer: parse dan normalisasi ke tipe aman.
- Application/domain layer: hanya menerima tipe yang sudah trusted.
- Integration layer: parse juga respons dari service eksternal sebelum dipakai lebih lanjut.
Begitu boundary ini konsisten, jumlah pengecekan defensif di tengah-tengah bisnis logic turun drastis.
Observability: parse error harus terlihat, bukan hanya gagal diam-diam
Jika parsing adalah gerbang utama kontrak API, maka kegagalannya perlu tercermin di log, metric, dan tracing.
Apa yang sebaiknya dicatat?
- Nama endpoint atau nama consumer webhook.
- Sumber integrasi, misalnya nama provider atau partner.
- Jenis parser yang gagal: body, query, auth header, webhook signature, response upstream.
- Daftar field yang gagal, jika tersedia dari library schema.
- Correlation ID atau request ID.
- Keputusan akhir: dibalas 400, 401, 422, atau diangkat sebagai incident upstream.
Namun hindari mencatat data sensitif secara mentah. Token, header auth, payload kartu, dan data pribadi harus disamarkan atau dipotong.
Metric yang berguna
- Jumlah parse failure per endpoint.
- Jumlah parse failure per partner/provider.
- Rasio invalid webhook terhadap total webhook masuk.
- Jumlah upstream response schema mismatch.
Schema mismatch dari provider eksternal sering menjadi sinyal dini bahwa integrasi berubah sebelum bug menyebar ke proses downstream.
Contoh struktur implementasi yang rapi
Anda tidak perlu membuat arsitektur rumit. Struktur sederhana berikut sudah cukup efektif:
// transport/http/create-order.ts
export async function createOrderHandler(req: HttpRequest) {
try {
const auth = parseBearerToken(req.headers.authorization);
const body = CreateOrderInputSchema.parse(req.body);
const idemKey = parseIdempotencyKey(req.headers['idempotency-key']);
const command = toCreateOrderCommand(body, idemKey);
const result = await createOrderUseCase(auth, command);
return ok(result);
} catch (err) {
return mapErrorToHttpResponse(err);
}
}
// domain/commands.ts
export function toCreateOrderCommand(
body: CreateOrderInput,
idempotencyKey: string
): CreateOrderCommand {
return {
customerId: body.customerId as CustomerId,
money: {
amount: body.amount,
currency: body.currency
},
idempotencyKey: idempotencyKey as IdempotencyKey
};
}Inti desainnya:
- Handler hanya menangani input/output dan mapping error.
- Parser berada dekat dengan boundary input.
- Use case tidak lagi menerima objek HTTP mentah.
Kesalahan umum saat menerapkan parse, jangan validasi
1. Menganggap TypeScript saja sudah cukup
TypeScript hanya bekerja saat kompilasi. Semua input dari jaringan tetap perlu validasi runtime atau parsing runtime. Jika tidak, Anda hanya punya rasa aman semu.
2. Tetap memakai any setelah parsing
Jika hasil parser masih dibuang ke any atau dipaksa dengan as secara berlebihan, manfaat pendekatan ini banyak hilang.
3. Parsing hanya request masuk, tapi percaya penuh pada upstream
Respons dari provider eksternal juga bagian dari boundary tak tepercaya. Perlakukan sama ketatnya dengan request dari klien.
4. Menaruh business rule di schema transport
Schema transport cocok untuk bentuk data, coercion, enum dasar, panjang string, dan pola format. Tetapi aturan seperti “merchant A tidak boleh memakai mata uang B” lebih cocok di domain layer. Jika semuanya dijejalkan ke parser HTTP, pemisahan tanggung jawab jadi kabur.
5. Semua error dianggap 400
Ini mempersulit klien, monitoring, dan retry policy. Bedakan error parse, auth, domain, dan upstream failure.
Debugging tips untuk edge case integrasi
- Jika parsing query sering gagal, periksa apakah framework Anda mengirim array, string tunggal, atau nilai kosong untuk parameter tertentu.
- Jika webhook signature validasi gagal, pastikan Anda memakai raw body yang benar saat verifikasi tanda tangan, bukan JSON yang sudah diubah bentuknya.
- Jika response provider tiba-tiba gagal parse, log status HTTP, header penting, dan potongan payload yang aman; jangan langsung retry tanpa batas.
- Jika idempotency terasa tidak konsisten, cek normalisasi key, scope penyimpanannya, dan apakah payload fingerprint ikut dibandingkan.
- Jika 422 terlalu sering muncul, evaluasi apakah sebagian error sebenarnya adalah 400 karena kontrak transport belum didefinisikan dengan cukup ketat.
Kapan parser manual lebih masuk akal dibanding schema library?
Schema library sangat membantu untuk kebanyakan API backend, tetapi parser manual tetap relevan jika:
- Aturan parsing sangat spesifik dan tidak nyaman ditulis deklaratif.
- Anda ingin error model yang sangat terkontrol.
- Dependency tambahan ingin diminimalkan.
Yang penting bukan nama library-nya, melainkan disiplin boundary: input mentah harus diubah menjadi tipe domain aman sebelum dipakai.
Penutup
Parse, jangan validasi bukan sekadar gaya penulisan kode, tetapi cara merancang kontrak API yang lebih tegas. Dalam TypeScript, manfaat terbesarnya muncul saat Anda berhenti mengalirkan data mentah yang “sudah dicek” dan mulai membangun objek domain yang memang aman dipakai.
Untuk integrasi backend, pendekatan ini memberi dampak praktis yang langsung terasa: request body lebih rapi, query params tidak ambigu, auth header lebih konsisten, webhook lebih tahan terhadap data liar, idempotency key lebih dapat diandalkan, dan respons pihak ketiga tidak lagi dipercaya mentah-mentah. Bonusnya, error 400/401/422 jadi lebih akurat, retry lebih cerdas, dan observability lebih berguna saat ada insiden.
Jika Anda ingin menerapkannya mulai hari ini, langkah paling efektif adalah sederhana: pilih satu endpoint integrasi yang paling rawan, ubah semua input eksternalnya menjadi unknown, parse di boundary, lalu izinkan domain layer menerima hanya tipe yang sudah trusted.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!