Episode ini membedah kualitas API tRPC: validasi input dengan zod dan superstruct, penanganan error dengan TRPCError beserta formatError dan pemetaan HTTP status, serta pengelolaan response type inference dan fallback default values.

API yang hanya bisa "jalan" belum tentu aman. Episode 5 membawa kalian ke kualitas production: validasi input yang ketat, error handling yang terstruktur, dan response types yang terpelihara. Tiga hal inilah yang membedakan API siap pakai dari prototipe.
Kalian akan belajar memvalidasi input dengan zod dan superstruct, memakai TRPCError untuk menandai kegagalan, memetakan error ke HTTP status yang tepat, dan mengelola tipe response beserta nilai default-nya.
zod sudah dipakai sejak episode 3. Sekarang perkuat schema-nya untuk skenario nyata:
import { z } from "zod";
const RegisterInput = z.object({
email: z.string().email(),
password: z.string().min(8),
umur: z.number().int().min(17).optional(),
alamat: z.object({
kota: z.string().min(1),
kodePos: z.string().regex(/^\d{5}$/),
}),
});
register: publicProcedure
.input(RegisterInput)
.mutation(({ input }) => {
// input.email dan input.alamat.kota sudah bertipe aman
return { sukses: true, email: input.email };
}),Dengan schema di atas, client yang mengirim email tidak valid, password pendek, atau kode pos bukan lima digit akan ditolak sebelum resolver berjalan. Tipe input di resolver otomatis mengikuti schema — tidak ada duplikasi antara runtime dan tipe.
Jika project sudah memakai superstruct, tRPC tetap mendukungnya. Satu-satunya syarat: objek schema harus memenuhi kontrak yang mirip zod, biasanya dengan membungkus validasi custom lewat fungsi kecil. Prinsipnya tetap sama: validasi berjalan di sisi server dan menghasilkan tipe yang bisa diinferensikan. Untuk project baru, zod adalah pilihan paling umum karena integrasinya langsung dan DSL-nya ringkas.
Melempar new Error() akan menghasilkan status HTTP 500 dan pesan yang tidak terstruktur. tRPC menyediakan TRPCError yang membawa code — kode ini dipetakan ke HTTP status secara otomatis:
import { TRPCError } from "@trpc/server";
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
const user = daftarUser.find((u) => u.id === input.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `User dengan id ${input.id} tidak ditemukan`,
});
}
return user;
}),throw new TRPCError({ code: "NOT_FOUND", message }) mengubah respons menjadi status HTTP 404 dengan bentuk error JSON-RPC yang konsisten.
Beberapa code yang sering dipakai dan status HTTP-nya:
BAD_REQUEST → 400, untuk input yang tidak valid.UNAUTHORIZED → 401, untuk akses tanpa autentikasi.FORBIDDEN → 403, untuk akses yang dilarang.NOT_FOUND → 404, untuk resource yang tidak ada.CONFLICT → 409, untuk duplikat data.TOO_MANY_REQUESTS → 429, untuk rate limiting.INTERNAL_SERVER_ERROR → 500, untuk kegagalan tak terduga.Menggunakan code yang tepat membuat client bisa bereaksi sesuai status tanpa parsing pesan.
Error di tRPC berformat JSON-RPC. Kalian bisa memformat ulang lewat formatError agar semua error memakai bentuk yang konsisten, misalnya menambahkan timestamp atau menyembunyikan detail internal:
import { initTRPC, TRPCError } from "@trpc/server";
const t = initTRPC.create({
formatError: ({ error, shape }) => {
return {
...shape,
message: error.message,
data: {
...shape.data,
timestamp: new Date().toISOString(),
},
};
},
});formatError({ error, shape }) menerima error asli dan bentuk default, lalu mengembalikan bentuk baru. Di sini kita menambahkan timestamp pada setiap error yang keluar. Detail di shape.data juga bisa diisi dengan kode internal untuk memudahkan debugging.
Di production, pesan error dari database jangan ditampilkan mentah-mentah. Pola umum: di formatError, sembunyikan error.cause dan tampilkan pesan generik untuk kode internal. Episode 12 akan membahas sisi keamanan ini lebih dalam.
Tipe response selalu diturunkan dari nilai yang dikembalikan resolver. Jika ada banyak bentuk response, gunakan union type agar client siap menangani semua kemungkinan:
import { z } from "zod";
cari: publicProcedure
.input(z.object({ kata: z.string() }))
.query(({ input }) => {
const hasil = daftarUser.filter((u) =>
u.nama.toLowerCase().includes(input.kata.toLowerCase()),
);
return {
total: hasil.length,
users: hasil,
};
}),Client tahu bahwa total bertipe number dan users adalah array user — tanpa dokumentasi terpisah.
Tidak semua response harus mengembalikan data penuh. Definisikan nilai default agar konsumen tidak crash saat data belum tersedia:
stats: publicProcedure.query(() => {
const total = daftarUser.length;
return {
totalUsers: total,
lastSync: total > 0 ? "2026-08-10T00:00:00Z" : null,
};
});Di sisi client, nilai seperti lastSync yang bisa null ditangani dengan aman oleh tipe — compiler memaksa kalian memeriksa null sebelum memakainya. Di React Query, pola fallback lain adalah memberi default di hook:
const { data = [] } = trpc.user.list.useQuery();trpc.user.list.useQuery() dengan default = [] membuat komponen tetap aman sebelum data tiba.
Tip
Kombinasikan validasi output dari episode 3 dengan formatError: input yang ketat mencegah data rusuk masuk, output yang divalidasi mencegah data rusuk keluar, dan error yang terformat membuat client selalu mendapat bentuk yang bisa diprediksi.
Episode 5 membuat API kalian tangguh: input divalidasi di sisi server, kegagalan ditandai dengan TRPCError yang terpetakan ke HTTP status yang benar, bentuk error diseragamkan lewat formatError, dan response types serta fallback values terjaga di kedua sisi.
Inti yang harus dibawa pulang:
TRPCError membawa code yang dipetakan ke HTTP status otomatis.BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND.formatError menyeragamkan bentuk error global dan bisa menambah metadata.Di episode 6 selanjutnya kita akan membahas middleware, links & lifecycle interception — middleware untuk autentikasi, logging, tracing, dan rate limit; loggerLink, httpBatchLink, wsLink, dan custom link; serta lifecycle hooks onError, onSuccess, dan onSettled di integrasi React Query.