Learning tRPC - Input Validation, Error Handling & Response Types
Episode 5 of 19

Learning tRPC - Input Validation, Error Handling & Response Types

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.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

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.

Input Validation with zod and superstruct

Deepening zod Schemas

zod has been used since episode 3. Now strengthen your schemas for real-world scenarios:

A complex input schema
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.

The superstruct Alternative

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.

Error Handling with TRPCError

Why Plain Errors Are Not Enough

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:

Using TRPCError
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.

HTTP Status Mapping

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.

formatError and Structured Error Responses

Shaping a Global Error Format

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:

formatError on the router
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.

Don't Leak Internal Details

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 Type Inference and Fallback Default Values

Consistent Response Types

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:

Union response
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.

Fallback Default Values

Not every response has to return full data. Define default values so consumers don't crash when data is not yet available:

Fallback default value
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:

Default value on the client
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.

Conclusion

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:

  • Validate input with zod; superstruct is also supported with adaptation.
  • TRPCError carries a code that is mapped to an HTTP status automatically.
  • Use the right codes: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND.
  • formatError unifies the global error shape and can add metadata.
  • Response types are inferred from the resolver; use output schemas for a strict contract.
  • Provide fallback default values on the client so components stay safe when data is empty.

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.

Learning tRPC - Input Validation, Error Handling & Response Types | Learning tRPC