TRPC + Zod: How to automatically connect your forms to backend field-level validations.

Why do I need this?

Hooking up form-level and field-level errors to Zod validations in TRPC is very powerful because it saves you a lot of effort.

  1. You never have to do frontend validations again.

  2. You can generically hook up forms with TRPC actions.

  3. You could automatically generate forms that are connected to Zod errors in your frontend code (left as an exercise for the reader).

Approach

I hooked into vanilla TRPC's errorFormatter method override to customize my errors in the backend. This way, my backend would always return a consistent error in case of validation issues.

I found out that Zod had the flatten() function, which easily provided form-level and field-level errors.

Here's how I approached it.

Backend

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;

Frontend

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>

End result

Nice form and field level validations!