How can I set up tRPC so that when zod throws an error I can handle it instead of tRPC. I have looked everywhere for an answer and I can't find one
For me, I wanted form-level and field-level errors. I was also using SvelteKit instead of Next, so the onError
method in the other answer on this question did not work for me... nor would it work for anyone not using Nextjs. I had to use vanilla TRPC's errorFormatter method override instead of onError
.
I found out that Zod had the flatten()
function, which easily provided form-level and field-level errors. This would help me hook the errors directly into my form and my fields... and could even be integrated into a form framework for automation of validations. (Super handy! Makes you so much more productive)
So because of this, I really wanted to do it the right way... i.e. I wanted to modify the custom errors in the backend, so that my backend would always return a consistent error in case of validation issues. I also wanted some semblance of typesafety in the frontend.
Here's how I approached it.
First I defined my types like so.
// backend/src/trpc/types.ts
import { typeToFlattenedError } from "zod";
import type { RuntimeConfig } from '@trpc/server/src/core/internals/config';
import { TRPC_ERROR_CODE_NUMBER } from '@trpc/server/src/rpc';
import { TRPCErrorShape } from '@trpc/server/src/rpc/envelopes';
// Utility type to replace the return type of a function
export type ReplaceReturnType<T extends (...a: any) => any, TNewReturn> = (...a: Parameters<T>) => TNewReturn;
// Flattened zod error
export type FlattenedZodError = typeToFlattenedError<any, string>
// This obscure type is taken from TrpcErrorShape, and extends the data property with "inputValidationError"
export type CustomErrorShape = TRPCErrorShape<
TRPC_ERROR_CODE_NUMBER,
Record<string, unknown> & { inputValidationError: FlattenedZodError | null }
>
// This type extends the errorFormatter property with the custom error shape
export type CustomErrorFormatter = ReplaceReturnType<RuntimeConfig<any>['errorFormatter'], CustomErrorShape>;
Then I defined my errorFormatter.
// backend/src/trpc/errorFormatter.ts
import { ZodError, } from 'zod';
import { CustomErrorFormatter } from './types';
export const errorFormatter: CustomErrorFormatter = (opts) => {
const { shape, error } = opts;
const isInputValidationError = error.code === "BAD_REQUEST" && error.cause instanceof ZodError
if (isInputValidationError) {
console.log(error.cause.flatten());
}
return {
...shape,
data: {
...shape.data,
inputValidationError: isInputValidationError ? error.cause.flatten() : null
}
}
}
And then I added it to TRPC like this
// backend/src/trpc/instance.trpc
import { initTRPC } from "@trpc/server";
import { errorFormatter } from "./errorFormatter";
const t = initTRPC.create({
errorFormatter
});
// router
export const router = t.router;
export const publicProcedure = t.procedure;
And I can use it in the frontend like this (in SvelteKit.. but it's pretty much just as easy in whatever frontend framework you're using)
<script lang="ts">
// frontend/src/lib/auth/LoginForm.svelte
import type { FlattenedZodError } from 'backend/src/trpc/types';
import { trpcClient as t } from '$lib/trpc';
let email: string = '';
let password: string = '';
let _formErrors: FlattenedZodError['formErrors'];
let _fieldErrors: FlattenedZodError['fieldErrors'];
const doLogin = async () => {
try {
const result = await t.user.login.mutate({
email,
password
});
if (result.token) {
localStorage.setItem('token', result.token);
window.location.href = '/';
}
} catch (e: any) {
if (e.data && e.data.inputValidationError != null) {
// here is where we get the errors
const { fieldErrors, formErrors } = e.data.inputValidationError as FlattenedZodError;
_fieldErrors = fieldErrors;
_formErrors = formErrors;
} else {
console.log('Unknown error', e);
}
}
};
</script>
<div class="card bg-base-200">
<div class="card-body">
<div class="card-title">
<h1 class="header-2">Login</h1>
</div>
{#if _formErrors?.length > 0}
<div class="alert alert-error flex flex-col gap-2">
{#each _formErrors as error}
<p>{error}</p>
{/each}
</div>
{/if}
<form on:submit={doLogin} class="flex flex-col gap-4">
<div>
<input
class={'input input-bordered input-ghost ' + (_fieldErrors?.email ? 'input-error' : '')}
type="email"
placeholder="Email"
bind:value={email}
/>
{#if _fieldErrors?.email}
<div class="text-error flex flex-col gap-0">
{#each _fieldErrors.email as error}
<p>{error}</p>
{/each}
</div>
{/if}
</div>
<div>
<input
class={'input input-bordered input-ghost ' +
(_fieldErrors?.password ? 'input-error' : '')}
type="password"
placeholder="Password"
bind:value={password}
/>
{#if _fieldErrors?.password}
<div class="text-error flex flex-col gap-2">
{#each _fieldErrors.password as error}
<p>{error}</p>
{/each}
</div>
{/if}
</div>
<input type="submit" class="btn btn-primary" value="Submit" />
</form>
</div>
</div>