feat: custom type-safe errors
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:
- Have a
.errorsmethod 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). - Have access to the defined errors in middlewares (
.use) and procedures (.query,.mutationand.subscription) to be able to throw them. - 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
safefrom oRPC).- Subscription resolvers wouldn't need this utility, as we can already have the typed error in the
onErrorcallback.
- Subscription resolvers wouldn't need this utility, as we can already have the typed error in the
Here's an example of what it could look like:
// 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 definedDescribe 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!
Source: trpc/trpc