This episode breaks down tRPC API quality: input validation with zod and superstruct, error handling with TRPCError along with formatError and HTTP status mapping, and managing response type inference and fallback default values.

An API that merely "runs" is not necessarily safe. Episode 5 takes you to production quality: strict input validation, structured error handling, and well-maintained response types. These three things are what distinguish a production-ready API from a prototype.
You will learn to validate input with zod and superstruct, use TRPCError to mark failures, map errors to the correct HTTP status, and manage response types along with their default values.
zod has been used since episode 3. Now strengthen your schemas for real-world scenarios:
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 };
}),With the schema above, a client sending an invalid email, a short password, or a postal code that is not five digits will be rejected before the resolver runs. The input type in the resolver automatically follows the schema — there is no duplication between runtime and types.
If your project already uses superstruct, tRPC still supports it. The only requirement: the schema object must satisfy a zod-like contract, usually by wrapping custom validation in a small function. The principle stays the same: validation runs on the server side and produces an inferable type. For new projects, zod is the most common choice because of its direct integration and concise DSL.
Throwing new Error() results in HTTP status 500 and an unstructured message. tRPC provides TRPCError which carries a code — this code is mapped to an HTTP status automatically:
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 }) turns the response into HTTP status 404 with a consistent JSON-RPC error shape.
Some frequently used codes and their HTTP statuses:
BAD_REQUEST → 400, for invalid input.UNAUTHORIZED → 401, for unauthenticated access.FORBIDDEN → 403, for forbidden access.NOT_FOUND → 404, for a missing resource.CONFLICT → 409, for duplicate data.TOO_MANY_REQUESTS → 429, for rate limiting.INTERNAL_SERVER_ERROR → 500, for unexpected failures.Using the right code lets the client react according to the status without parsing messages.
Errors in tRPC are formatted as JSON-RPC. You can reformat them through formatError so that every error uses a consistent shape, for example adding a timestamp or hiding internal details:
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 }) receives the original error and the default shape, then returns a new shape. Here we add a timestamp to every outgoing error. Details in shape.data can also be filled with an internal code to ease debugging.
In production, database error messages should not be shown raw. A common pattern: in formatError, hide error.cause and show a generic message for internal codes. Episode 12 will dig deeper into this security aspect.
Response types are always inferred from the value returned by the resolver. If there are many response shapes, use a union type so the client is ready to handle every possibility:
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,
};
}),The client knows that total is typed as number and users is an array of users — without separate documentation.
Not every response has to return full data. Define default values so consumers don't crash when data is not yet available:
stats: publicProcedure.query(() => {
const total = daftarUser.length;
return {
totalUsers: total,
lastSync: total > 0 ? "2026-08-10T00:00:00Z" : null,
};
});On the client side, values like lastSync that can be null are handled safely by the type system — the compiler forces you to check for null before using it. In React Query, another fallback pattern is to provide a default in the hook:
const { data = [] } = trpc.user.list.useQuery();trpc.user.list.useQuery() with a default of = [] keeps the component safe before the data arrives.
Tip
Combine the output validation from episode 3 with formatError: strict input prevents bad data from entering, validated output prevents bad data from leaving, and formatted errors ensure the client always receives a predictable shape.
Episode 5 makes your API robust: input is validated on the server side, failures are marked with TRPCError mapped to the correct HTTP status, the error shape is unified through formatError, and response types along with fallback values are maintained on both sides.
Key takeaways:
TRPCError carries a code that is mapped to an HTTP status automatically.BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND.formatError unifies the global error shape and can add metadata.In the next episode, episode 6, we will discuss middleware, links & lifecycle interception — middleware for authentication, logging, tracing, and rate limiting; loggerLink, httpBatchLink, wsLink, and custom links; as well as the lifecycle hooks onError, onSuccess, and onSettled in React Query integration.