#6832·trpc

feat: custom type-safe errors

Author: heitorlisboaCreated Jun 22, 2025Updated Sep 13, 2026

Describe the feature you'd like to request

tRPC is awesome for providing end-to-end type safety, but there's one specific area in which the library doesn't help very much, which is when dealing with errors.

What I want is a way to throw app-specific error codes from the server and have these error codes typed in the client. And not only that, I also want the error codes to be attached to each specific procedure, not to the entire router.

For example, if procedure A might throw errors X and Y, and procedure B might throw error Z, then, when calling procedure A, you would only have the possible types of errors be X, Y, or a generic error (for unexpected errors).

Related issue: #3438

Describe the solution you'd like to see

The solution I propose is heavily inspired by oRPC's error handling, where you define the expected errors in a per-procedure basis.

Here's a breakdown of what I want:

  1. Have a .errors method in the procedure builder, where you define your app-specific error codes, their respective tRPC error codes (e.g., BAD_REQUEST, NOT_FOUND, etc.), and optionally some data that needs to be included when throwing the error (using Standard Schema).
  2. Have access to the defined errors in middlewares (.use) and procedures (.query, .mutation and .subscription) to be able to throw them.
  3. In the client, know which errors types each procedure expects to throw, and be able to extract these errors through a utility function (I'm currently borrowing the name safe from oRPC).
    • Subscription resolvers wouldn't need this utility, as we can already have the typed error in the onError callback.

Here's an example of what it could look like:

typescript
// Defining and throwing the errors in the server
const protectedProcedure = publicProcedure
  .errors({
    UNAUTHORIZED: {
      code: 'UNAUTHORIZED',
    },
  })
  .use(async ({ ctx, errors, next }) => {
    if (!ctx.user) {
      throw new errors.UNAUTHORIZED();
    }
    return next({
      ctx: {
        user: ctx.user,
      },
    });
  });

export const appRouter = router({
  getPost: protectedProcedure
    .errors({
      POST_NOT_FOUND: {
        code: 'NOT_FOUND',
        data: z.object({
          someUsefulField: z.string(),
        }),
      },
    })
    .input(z.object({ id: z.number() }))
    .query(({ input, errors }) => {
      const post = db.posts.find((post) => post.id === input.id);
      if (!post) {
        throw new errors.POST_NOT_FOUND({
          message: 'Post not found',
          data: { someUsefulField: 'someUsefulField' },
        });
      }
      return post;
    }),
});

// Client-side error handling
const [data, error] = await safe(client.getPost.query({ id: 1 }));
/* The array would be a union of
  | [
      undefined,
      | CustomTRPCClientError<
          'UNAUTHORIZED',
          'UNAUTHORIZED',
          undefined
        >
      | CustomTRPCClientError<
          'NOT_FOUND',
          'POST_NOT_FOUND',
          { someUsefulField: string }
        >
      | TRPCClientError
    ]
  | [{ id: number; title: string }, undefined]
*/
if (error) {
  // `error.data.customCode` should be typed as `'UNAUTHORIZED' | 'POST_NOT_FOUND' | undefined`
  if (error.data?.customCode === 'UNAUTHORIZED') {
    console.error('Unauthorized, please log in');
    return;
  }
  if (error.data?.customCode === 'POST_NOT_FOUND') {
    console.error('Post not found', error.data.customData.someUsefulField);
    return;
  }
  // Handle the unexpected error...
  return;
}
// `data` is defined

Describe alternate solutions

We can already have type-safe app-specific errors codes through error formatting (example), but the error codes would be shared throughout the whole tRPC API.

Additional information

I already have a working prototype of the implementation I suggested, and I might open a PR in a few days after I make some further adjustments.

‍‍ Contributing

  • ‍♂️ Yes, I'd be down to file a PR implementing this feature!