Input-типы и обработка ошибок мутаций

Input-типы группируют аргументы мутации в один объект, а паттерн payload с полем errors превращает ожидаемые ошибки в часть данных, а не в технический сбой.

Хорошая мутация принимает один аккуратный input и возвращает payload, где и результат, и понятные пользователю ошибки — это часть нормального ответа.

Когда у мутации много аргументов, перечислять их по одному неудобно и легко перепутать порядок. Input-тип — специальный тип для входных данных, объявляется ключевым словом input:

input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

Input-типы отличаются от обычных object-типов: их поля могут быть только скалярами, enum или другими input-типами — внутрь нельзя положить object-тип с резолверами. Это вход, а не результат. Логика тут простая: object-тип может содержать вычисляемые поля и связи, которые сервер резолвит на лету, — а входные данные клиент присылает целиком и сразу, резолвить там нечего. Поэтому GraphQL разводит эти две роли на разные виды типов. Ещё один плюс input-типа — переиспользование: один и тот же CreateUserInput можно принять и в мутации регистрации, и в импорте пользователей, не дублируя список полей. А раз input-поля типизированы (включая ! и enum), невалидные данные отсекаются ещё на валидации схемы, не доходя до твоей бизнес-логики.

Паттерн payload с ошибками

Технические сбои (упала БД) GraphQL кладёт в общий массив errors. Но ожидаемые ошибки бизнес-логики — «email занят», «слишком короткий пароль» — это часть нормального сценария. Их удобно возвращать прямо в данных, через тип-payload:

type UserError {
  field: String
  message: String!
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

Тогда клиент в одном ответе получает либо созданного user, либо список понятных errors с привязкой к полям формы:

{
  "data": {
    "createUser": {
      "user": null,
      "errors": [{ "field": "email", "message": "Email уже занят" }]
    }
  }
}

Как работает под капотом

Резолвер мутации получает input целиком, валидирует бизнес-правила и решает, что положить в payload: данные или ошибки. Смоделируем такой резолвер:

const users = [{ email: "[email protected]" }];

function createUser(_parent, { input }) {
  const errors = [];
  if (!input.name) errors.push({ field: "name", message: "Имя обязательно" });
  if (!/.+@.+/.test(input.email))
    errors.push({ field: "email", message: "Некорректный email" });
  if (users.some(u => u.email === input.email))
    errors.push({ field: "email", message: "Email уже занят" });

  if (errors.length) return { user: null, errors };

  const user = { id: users.length + 1, ...input };
  users.push(user);
  return { user, errors: [] };   // payload
}

console.log(createUser(null, { input: { name: "Аня", email: "[email protected]" } }));
console.log(createUser(null, { input: { name: "Боб", email: "[email protected]" } }));

Попробуй сам ▶ — передай пустое имя или занятый email и посмотри, как ошибки приезжают в errors payload, а не роняют запрос.

Частые ошибки

  • Класть бизнес-ошибки в общий массив errors. «Email занят» — это нормальный ответ формы, а не сбой сервера. В общем errors такие вещи неудобно привязывать к полям.
  • Гигантский список аргументов. Десять отдельных аргументов вместо одного input-типа — хрупко и нечитаемо.
  • Возвращать только true/false. Клиент не узнает почему не получилось и не подсветит проблемное поле формы.

Best practices

  • Для каждой мутации заводи свой input-тип (CreateUserInput) и свой payload-тип (CreateUserPayload) — это масштабируемое соглашение.
  • Различай два класса ошибок: технические -> в errors верхнего уровня; ожидаемые бизнес-ошибки -> в поле errors внутри payload с привязкой к field.
  • Делай input-поля настолько строгими, насколько возможно (String!, enum), чтобы невалидные данные отсекались ещё на валидации схемы.

Итоги

Input-типы группируют аргументы мутации в один объект и содержат только скаляры, enum и другие input-типы. Паттерн payload с полем errors возвращает ожидаемые бизнес-ошибки как часть данных, привязывая их к полям формы, тогда как технические сбои уходят в общий массив errors. Дальше — подписки и данные в реальном времени.

Проверьте себя
1. Чем input-тип отличается от обычного object-типа?
AОн может содержать только скаляры, enum и другие input-типы и используется для входных данных
BОн быстрее
CУ него обязательно есть резолверы
DОн возвращается из Query
2. Куда лучше класть ожидаемую бизнес-ошибку «email занят»?
AВ общий массив errors верхнего уровня
BВ поле errors внутри payload мутации, с привязкой к field
CВ HTTP-статус 409
DВ консоль сервера