Обработка ошибок с типами

Библиотека @hookform/resolvers предоставляет адаптер yupResolver, который связывает схемы валидации из Yup с формами из React Hook Form. Одной из наиболее важных задач при работе с типизированными формами становится корректная обработка ошибок: получение безопасных типов, доступ к вложенным полям и создание универсальных механизмов отображения ошибок.


Структура объекта ошибок

После подключения yupResolver объект ошибок хранится внутри formState.errors.

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: yupResolver(schema),
});

Тип errors автоматически строится на основе интерфейса формы.

Пример:

type FormValues = {
  email: string;
  password: string;
};

const {
  formState: { errors },
} = useForm<FormValues>({
  resolver: yupResolver(schema),
});

Теперь:

errors.email
errors.password

имеют строгую типизацию.


Тип FieldError

Каждая ошибка представляет собой объект типа FieldError.

Пример структуры:

{
  type: "required",
  message: "Поле обязательно",
  ref: HTMLInputElement
}

Тип:

import { FieldError } from "react-hook-form";

Использование:

const emailError: FieldError | undefined = errors.email;

Типизация вложенных ошибок

При наличии вложенных объектов структура ошибок повторяет структуру формы.

Схема

const schema = yup.object({
  profile: yup.object({
    firstName: yup.string().required(),
    lastName: yup.string().required(),
  }),
});

Типы

type FormValues = {
  profile: {
    firstName: string;
    lastName: string;
  };
};

Ошибки

errors.profile?.firstName?.message
errors.profile?.lastName?.message

Типы автоматически определяются как:

FieldError | undefined

Ошибки массивов

Схема

const schema = yup.object({
  users: yup.array(
    yup.object({
      email: yup.string().email().required(),
    })
  ),
});

Типы

type FormValues = {
  users: {
    email: string;
  }[];
};

Доступ к ошибкам

errors.users?.[0]?.email?.message

Каждый элемент массива также типизируется автоматически.


Тип FieldErrors

Для полной структуры ошибок используется тип FieldErrors<T>.

import { FieldErrors } from "react-hook-form";

type FormValues = {
  email: string;
  password: string;
};

const errors: FieldErrors<FormValues>;

Полезно при передаче ошибок в компоненты.


Передача ошибок в дочерние компоненты

Родительский компонент

<FormInput
  label="Email"
  error={errors.email}
/>

Компонент поля

import { FieldError } from "react-hook-form";

type Props = {
  label: string;
  error?: FieldError;
};

export const FormInput = ({ label, error }: Props) => {
  return (
    <div>
      <label>{label}</label>

      {error && (
        <p>{error.message}</p>
      )}
    </div>
  );
};

Типизация универсального компонента поля

При создании переиспользуемых компонентов удобнее использовать generic-типы.

import {
  FieldErrors,
  FieldValues,
  Path,
} from "react-hook-form";

Компонент

type Props<T extends FieldValues> = {
  name: Path<T>;
  errors: FieldErrors<T>;
};

function InputField<T extends FieldValues>({
  name,
  errors,
}: Props<T>) {
  return (
    <div>
      <p>{errors[name]?.message}</p>
    </div>
  );
}

Проблема индексации errors[name]

errors[name] может вызывать ошибку TypeScript:

Element implicitly has an 'any' type

Причина — Path<T> может быть вложенным значением (profile.email), а объект ошибок имеет сложную структуру.


Использование lodash.get

Распространённое решение:

npm install lodash.get
import get from "lodash.get";

const error = get(errors, name);

<p>{error?.message}</p>

Типизация get

Безопасная версия:

const error = get(errors, name) as FieldError | undefined;

Использование ErrorMessage

Библиотека React Hook Form содержит готовый компонент ошибок.

npm install @hookform/error-message

Пример

import { ErrorMessage } from "@hookform/error-message";

<ErrorMessage
  errors={errors}
  name="email"
  render={({ message }) => (
    <p>{message}</p>
  )}
/>

Типизация ErrorMessage

При использовании generic-типов:

type FormValues = {
  email: string;
};
<ErrorMessage<FormValues>
  errors={errors}
  name="email"
/>

Режим criteriaMode

По умолчанию Yup возвращает только первую ошибку поля.

Для получения всех ошибок:

useForm({
  resolver: yupResolver(schema),
  criteriaMode: "all",
});

Типизация множественных ошибок

Теперь объект ошибки содержит поле types.

errors.password?.types

Пример:

{
  required: "Введите пароль",
  min: "Минимум 8 символов"
}

Тип MultipleFieldErrors

import { MultipleFieldErrors } from "react-hook-form";

Использование:

const types: MultipleFieldErrors | undefined =
  errors.password?.types;

Отображение нескольких ошибок

{
  errors.password?.types &&
    Object.values(errors.password.types).map((message) => (
      <p key={message}>{message}</p>
    ));
}

Обработка root-ошибок

Иногда ошибка относится не к конкретному полю, а ко всей форме.

Например:

  • неверные учётные данные;
  • ошибка сервера;
  • конфликт данных;
  • недоступность API.

setError

const {
  setError,
} = useForm<FormValues>();

Пример

setError("root.serverError", {
  type: "server",
  message: "Неверный логин или пароль",
});

Типизация root-ошибок

errors.root?.serverError?.message

Ошибки после асинхронной проверки

Пример

const onSub mit = async (data: FormValues) => {
  const exists = await checkEmail(data.email);

  if (exists) {
    setError("email", {
      type: "manual",
      message: "Email уже используется",
    });

    return;
  }
};

Типы ошибок Yup

Yup выбрасывает объект ValidationError.

import { ValidationError } from "yup";

Структура ValidationError

{
  name: "ValidationError",
  path: "email",
  message: "Некорректный email",
  errors: [],
  inner: []
}

Обработка ValidationError вручную

Иногда схема валидируется вне React Hook Form.

Пример

try {
  await schema.validate(data, {
    abortEarly: false,
  });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.errors);
  }
}

inner-массив ошибок

При abortEarly: false Yup собирает все ошибки.

error.inner

Пример:

[
  {
    path: "email",
    message: "Введите email"
  },
  {
    path: "password",
    message: "Минимум 8 символов"
  }
]

Преобразование ValidationError в объект

const formatted = error.inner.reduce(
  (acc, current) => {
    if (current.path) {
      acc[current.path] = current.message;
    }

    return acc;
  },
  {} as Record<string, string>
);

Создание типизированного helper

type ErrorMap<T> = Partial<
  Record<keyof T, string>
>;

Использование

function mapYupErrors<T>(
  error: ValidationError
): ErrorMap<T> {
  return error.inner.reduce((acc, current) => {
    if (current.path) {
      acc[current.path as keyof T] =
        current.message;
    }

    return acc;
  }, {} as ErrorMap<T>);
}

Безопасная работа с optional errors

Ошибки всегда могут отсутствовать.

Неправильно:

errors.email.message

Правильно:

errors.email?.message

Nullish coalescing

<p>
  {errors.email?.message ?? " "}
</p>

Опциональные поля и ошибки

Схема

const schema = yup.object({
  middleName: yup.string().optional(),
});

Даже если поле необязательное, ошибка всё равно может появиться.

Например:

middleName: yup
  .string()
  .max(10)
  .optional()

Типизация кастомных тестов

Пример

const schema = yup.object({
  password: yup
    .string()
    .test(
      "has-uppercase",
      "Нужна заглавная буква",
      (value) => {
        return /[A-Z]/.test(value || "");
      }
    ),
});

Ошибка будет иметь тип:

type: "has-uppercase"

Использование type ошибки

if (errors.password?.type === "has-uppercase") {
  console.log("Ошибка регистра");
}

Локализация ошибок

Глобальная настройка

yup.setLocale({
  mixed: {
    required: "Поле обязательно",
  },
});

Типизация локализованных сообщений

Все сообщения остаются строками:

message?: string

Создание enum для типов ошибок

enum ValidationErrorType {
  REQUIRED = "required",
  MIN = "min",
  MAX = "max",
}

Сравнение типа ошибки

if (
  errors.password?.type ===
  ValidationErrorType.MIN
) {
  console.log("Слишком короткий пароль");
}

Кастомный обработчик ошибок

function getErrorMessage(
  error?: FieldError
) {
  return error?.message || "";
}

Типизированный helper для массива ошибок

function getArrayError(
  errors: FieldErrors<FormValues>,
  index: number
) {
  return errors.users?.[index]?.email?.message;
}

Интеграция с UI-библиотеками

Material UI

<TextField
  error={!!errors.email}
  helperText={errors.email?.message}
/>

Chakra UI

<FormErrorMessage>
  {errors.email?.message}
</FormErrorMessage>

Ошибки при трансформации данных

Yup может изменять значения через transform.

Пример

yup.string().transform((value) => {
  return value.trim();
});

Ошибка будет относиться уже к преобразованному значению.


strict mode

schema.validate(data, {
  strict: true,
});

Трансформации отключаются, что может изменить поведение ошибок.


abortEarly

abortEarly: true

schema.validate(data, {
  abortEarly: true,
});

Только первая ошибка.


abortEarly: false

schema.validate(data, {
  abortEarly: false,
});

Все ошибки одновременно.


Влияние abortEarly на UX

abortEarly: false особенно полезен:

  • при длинных формах;
  • при регистрации;
  • при сложных паролях;
  • при множественной валидации.

Отладка ошибок YupResolver

Логирование

console.log(errors);

Просмотр структуры ValidationError

catch (error) {
  console.log(JSON.stringify(error, null, 2));
}

Типизация catch-переменной

В TypeScript переменная error имеет тип unknown.

Правильная проверка:

catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.message);
  }
}

Использование InferType

Yup умеет автоматически строить типы.

type FormValues = yup.InferType<typeof schema>;

Теперь ошибки синхронизированы со схемой.


Преимущество InferType

Без InferType:

type FormValues = {
  email: string;
};

Схема и типы могут разойтись.

С InferType типы всегда соответствуют Yup-схеме.


Полный пример

import * as yup from "yup";

import {
  useForm,
  FieldError,
} from "react-hook-form";

import { yupResolver } from "@hookform/resolvers/yup";

const schema = yup.object({
  email: yup
    .string()
    .email("Некорректный email")
    .required("Введите email"),

  password: yup
    .string()
    .min(8, "Минимум 8 символов")
    .required("Введите пароль"),
});

type FormValues = yup.InferType<typeof schema>;

export default function App() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: yupResolver(schema),
    criteriaMode: "all",
  });

  const onSub mit = async (
    data: FormValues
  ) => {
    try {
      await login(data);
    } catch {
      setError("root.serverError", {
        type: "server",
        message: "Ошибка авторизации",
      });
    }
  };

  return (
    <form onSub mit={handleSubmit(onSubmit)}>
      <input {...register("email")} />

      <p>{errors.email?.message}</p>

      <input
        type="password"
        {...register("password")}
      />

      <p>{errors.password?.message}</p>

      <p>
        {
          errors.root?.serverError?.message
        }
      </p>

      <button type="submit">
        Войти
      </button>
    </form>
  );
}