Localized tRPC errors

3 min read

tl;dr - A method for creating localized error messages with tRPC.


  • Using a custom error with localization keys
  • Associating errors with form fields
  • Full example

There are many cases where you want to present a server error to the user, i.e., when a user tries to sign up with an email that is already in use. In these cases, it’s important to present the error message in the user’s language. This is a guide on how to do that with tRPC.


We start with a project that was bootstrapped with create-t3-app. For internationalization we use next-intl and set it up as described in the getting started guide. With this initial project setup we can jump into implementing localized error messages.



Getting started


When localizing messages of any kind we need to decide where we want the localization to happen, i.e., the mapping from localization keys to the actual messages. An approach often used is to apply the localization at the last moment, namely, just before the user is presented with it on the client. This way we push the translation to the edge of the application and we can work with localization keys everywhere else.


Essentially, this means we want to send localization keys as part of a tRPC error and then map them to the translations on the client. To achieve this we extend the TRPCError class and add a i18nKey property to it:

import { TRPCError } from '@trpc/server';
import type { useTranslations } from 'next-intl';

type I18nNamespaceKeys = Exclude<Parameters<typeof useTranslations>[0], undefined>;
export type I18nKeys<T extends I18nNamespaceKeys = I18nNamespaceKeys>
  = Parameters<ReturnType<typeof useTranslations<T>>>[0];

export class I18nTRPCError extends TRPCError {
  public readonly i18nKey: I18nKeys;

  constructor(opts: {
    i18nKey: I18nKeys;
  } & ConstructorParameters<typeof TRPCError>[0]) {
    const { i18nKey, ...rest } = opts;
    super(rest);

    this.i18nKey = i18nKey;
  }
}

Additionally, we take advantage of next-intl’s types to type the i18nKey property. We can then use this new class to throw localized errors:

throw new I18nTRPCError({
  code: 'BAD_REQUEST',
  message: 'Post with this name already exists',
  i18nKey: 'postAlreadyExistsError',
});

and access the i18nKey property in the client:

const t = useTranslations('posts');

const createPost = api.post.create.useMutation({
  onError: (error) => {
    const message = t(error.data?.i18nKey);
    // Instead of logging it we would present it to the user for example with a toast
    console.log(message);
  },
});


Associating errors with form fields


We can go a step further though. When validating form fields on the server we would like to associate the error with the form field which caused it, so we can present the user with more context on what caused the error. At this point you might have already realized that we can achieve this by adding another field to our custom error, namely, formField which will reference the name of the affected form field:

export class I18nTRPCError extends TRPCError {
  public readonly i18nKey: I18nKeys;

  public readonly formField?: string;

  constructor(opts: {
    i18nKey: I18nKeys;
    formField?: string
  } & ConstructorParameters<typeof TRPCError>[0]) {
    const { i18nKey, formField, ...rest } = opts;
    super(rest);

    this.i18nKey = i18nKey;
    this.formField = formField;
  }
}

we set this field as optional since not every error will be associated with a form field. On the client side we can then use the formField to decide how to present the error. For example, we can use it to set the error message on the exact form field that caused the error:

const t = useTranslations('posts');

const createPost = api.post.create.useMutation({
  onError: (error) => {
    const message = t(error.data?.i18nKey);

    if (!error.data?.formField) {
      // Instead of logging it we would present it to the user for example with a toast
      console.log(message);
      return;
    }

    setError(error.data.formField, { message });
  },
});

You can find a full example of a simple use case which demonstrates how all parts work together here.