@letar/forms

Маппинг серверных ошибок

Автоматический маппинг ошибок Prisma, ZenStack и Zod на поля формы

Обзор

mapServerErrors() автоматически определяет формат ошибки и маппит её на поля формы.

import { applyServerErrors, mapServerErrors } from '@letar/forms'

;<Form
  schema={UserSchema}
  onSubmit={async ({ value }) => {
    try {
      await createUser(value)
    } catch (error) {
      const mapped = mapServerErrors(error, {
        fieldMap: {
          email: { field: 'email', message: 'Этот email уже зарегистрирован' },
        },
      })
      applyServerErrors(form, mapped)
    }
  }}
>
  <Form.Field.String name="email" />
  <Form.Errors />
  <Form.Button.Submit>Создать</Form.Button.Submit>
</Form>

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

Полный пример из песочницы form-develop-app, прочитан напрямую из исходников на сборке.

apps/form-develop-app/src/app/server-errors-demo/page.tsx
'use client'

import { Box, Code, Heading, HStack, Text, VStack } from '@chakra-ui/react'
import { actionFailure, Form, mapServerErrors, useFormRef, useFormServerAction } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout } from '../_components'
import { toaster } from '../_components/toaster'

// Имитация серверных ошибок разных типов
const ERROR_EXAMPLES = {
  'Prisma P2002 (unique)': { code: 'P2002', message: 'Unique constraint failed', meta: { target: ['email'] } },
  'Prisma P2002 (composite)': { code: 'P2002', message: 'Unique', meta: { target: ['organizationId', 'name'] } },
  'Prisma P2003 (FK)': { code: 'P2003', message: 'FK failed', meta: { field_name: 'categoryId' } },
  'Prisma P2025 (not found)': { code: 'P2025', message: 'Record not found' },
  'ZenStack policy': { reason: 'rejected-by-policy' as const },
  'ZenStack cannot-read-back': {
    reason: 'rejected-by-policy' as const,
    rejectedByPolicyReason: 'cannot-read-back' as const,
  },
  'ZenStack db-query + P2002': { reason: 'db-query-error' as const, code: 'P2002', meta: { target: ['email'] } },
  'Zod flatten': {
    formErrors: ['Пароли не совпадают'],
    fieldErrors: { email: ['Некорректный email'], password: ['Минимум 8 символов'] },
  },
  'ActionResult string': { success: false as const, error: 'Пользователь уже существует' },
  'ActionResult nested': {
    success: false as const,
    error: { formErrors: [], fieldErrors: { name: ['Обязательное поле'] } },
  },
  'ActionFailure (поле)': { success: false as const, error: 'Такой адрес уже занят', field: 'slug' },
  'Error объект': new Error('Что-то пошло не так'),
  null: null,
} as const

// --- Живой пример useFormServerAction ---

const SignupSchema = z.object({
  email: z.string().min(1).meta({ ui: { title: 'Email' } }),
}).strip()

type SignupData = z.infer<typeof SignupSchema>

/** Имитация server action — «занятый» email ведёт себя как Prisma P2002 на уникальном поле. */
async function fakeCreateUser(data: SignupData): Promise<{ id: string }> {
  await new Promise((resolve) => setTimeout(resolve, 600))
  if (data.email === 'taken@example.com') {
    throw { code: 'P2002', message: 'Unique constraint failed', meta: { target: ['email'] } }
  }
  return { id: crypto.randomUUID() }
}

function UseFormServerActionDemo() {
  const formRef = useFormRef()
  const { run, pending } = useFormServerAction(formRef, {
    fieldMap: { email: { field: 'email', message: 'Этот email уже зарегистрирован' } },
    toaster,
    successMessage: 'Пользователь создан',
  })

  return (
    <VStack align="stretch" gap={3} maxW="sm">
      <Text fontSize="sm" color="fg.muted">
        Введите{' '}
        <Code>taken@example.com</Code>, чтобы увидеть маппинг ошибки на поле + toast. Любой другой email — успех.
      </Text>
      <Form
        schema={SignupSchema}
        initialValue={{ email: '' }}
        formRef={formRef}
        onSubmit={async (data) => {
          await run(() => fakeCreateUser(data))
        }}
      >
        <Form.Errors />
        <Form.Field.String name="email" />
        <Form.Button.Submit loadingText="Отправка...">Создать</Form.Button.Submit>
      </Form>
      <Text fontSize="xs" color="fg.muted">
        pending: <Code>{String(pending)}</Code>
      </Text>
    </VStack>
  )
}

// --- Отказ Server Action значением (ActionFailure) ---

const CategorySchema = z.object({
  slug: z.string().min(1).meta({ ui: { title: 'Адрес (slug)' } }),
}).strip()

type CategoryData = z.infer<typeof CategorySchema>

/**
 * Имитация server action, которая ВОЗВРАЩАЕТ отказ значением (в production Next.js стирает текст
 * брошенной ошибки). На сервере это делает `catchActionFailure` из `@letar/forms/server-errors`.
 */
async function fakeCreateCategory(data: CategoryData) {
  await new Promise((resolve) => setTimeout(resolve, 600))
  if (data.slug === 'taken') {
    return actionFailure('Такой адрес уже занят — задайте другой', 'slug')
  }
  if (data.slug === 'locked') {
    return actionFailure('Категорию нельзя изменять: она используется в заказах')
  }
  return { id: crypto.randomUUID() }
}

function ActionFailureDemo() {
  const formRef = useFormRef()
  const { run, pending } = useFormServerAction(formRef, { toaster, successMessage: 'Категория создана' })

  return (
    <VStack align="stretch" gap={3} maxW="sm">
      <Text fontSize="sm" color="fg.muted">
        <Code>taken</Code> — отказ с полем (под полем и в общем блоке), <Code>locked</Code>{' '}
        — отказ без поля (только общий блок). Любой другой адрес — успех. Отказ приходит значением, а <Code>run</Code>
        {' '}
        сам бросает его как <Code>ActionFailureError</Code>.
      </Text>
      <Form
        schema={CategorySchema}
        initialValue={{ slug: '' }}
        formRef={formRef}
        onSubmit={async (data) => {
          await run(() => fakeCreateCategory(data))
        }}
      >
        <Form.Errors />
        <Form.Field.String name="slug" />
        <Form.Button.Submit loadingText="Отправка...">Создать</Form.Button.Submit>
      </Form>
      <Text fontSize="xs" color="fg.muted">
        pending: <Code>{String(pending)}</Code>
      </Text>
    </VStack>
  )
}

export default function ServerErrorsDemoPage() {
  const [selectedError, setSelectedError] = useState<string>('Prisma P2002 (unique)')

  const error = ERROR_EXAMPLES[selectedError as keyof typeof ERROR_EXAMPLES]
  const mapped = mapServerErrors(error, {
    fieldMap: {
      email: { field: 'email', message: 'Этот email уже зарегистрирован' },
      organizationId_name: { field: 'name', message: 'Такое название уже занято в организации' },
    },
  })

  return (
    <DemoPageLayout title="mapServerErrors()" description="Автоматический маппинг серверных ошибок на поля формы">
      <VStack gap={8} align="stretch">
        {/* Выбор типа ошибки */}
        <Box>
          <Heading size="md" mb={3}>
            Тип серверной ошибки
          </Heading>
          <HStack gap={2} flexWrap="wrap">
            {Object.keys(ERROR_EXAMPLES).map((key) => (
              <Box
                key={key}
                as="button"
                px={3}
                py={1.5}
                fontSize="sm"
                borderRadius="md"
                bg={selectedError === key ? 'blue.600' : 'gray.700'}
                color="white"
                cursor="pointer"
                onClick={() => setSelectedError(key)}
                _hover={{ bg: selectedError === key ? 'blue.500' : 'gray.600' }}
              >
                {key}
              </Box>
            ))}
          </HStack>
        </Box>

        {/* Входная ошибка */}
        <Box>
          <Heading size="sm" mb={2} color="red.400">
            Входные данные (error)
          </Heading>
          <Code display="block" whiteSpace="pre-wrap" fontSize="xs" p={4} borderRadius="md">
            {JSON.stringify(error, null, 2)}
          </Code>
        </Box>

        {/* Результат маппинга */}
        <Box>
          <Heading size="sm" mb={2} color="green.400">
            Результат mapServerErrors()
          </Heading>
          <Code display="block" whiteSpace="pre-wrap" fontSize="xs" p={4} borderRadius="md">
            {JSON.stringify(mapped, null, 2)}
          </Code>
        </Box>

        {/* Fieldmap конфиг */}
        <Box>
          <Heading size="sm" mb={2} color="blue.400">
            Конфигурация fieldMap
          </Heading>
          <Code display="block" whiteSpace="pre-wrap" fontSize="xs" p={4} borderRadius="md">
            {`fieldMap: {
  email: { field: 'email', message: 'Этот email уже зарегистрирован' },
  organizationId_name: { field: 'name', message: 'Такое название уже занято' },
}`}
          </Code>
        </Box>

        {/* Пример использования */}
        <Box>
          <Heading size="md" mb={3}>
            Пример использования в onSubmit
          </Heading>
          <Code display="block" whiteSpace="pre" fontSize="xs" p={4} borderRadius="md">
            {`import { mapServerErrors, applyServerErrors } from '@letar/forms'

<Form schema={UserSchema} onSubmit={async ({ value }) => {
  try {
    await createUser(value)
  } catch (error) {
    // Автодетект: Prisma P2002 → поле email
    const mapped = mapServerErrors(error, {
      fieldMap: {
        email: { field: 'email', message: 'Этот email занят' },
      },
    })

    // Вариант 1: применить к форме
    applyServerErrors(form, mapped)

    // Вариант 2: показать в toast
    if (mapped.formErrors.length) {
      toaster.error({ title: mapped.formErrors[0] })
    }
  }
}}>
  <Form.Field.String name="email" />
  <Form.Errors /> {/* Покажет ошибки из applyServerErrors */}
</Form>`}
          </Code>
        </Box>

        {/* useFormServerAction — та же связка в один вызов */}
        <Box>
          <Heading size="md" mb={3}>
            useFormServerAction — та же связка в один вызов
          </Heading>
          <Text fontSize="sm" color="fg.muted" mb={3}>
            Обёртка над примером выше: `formRef` + `mapServerErrors`/`applyServerErrors` + pending-состояние +
            опциональный toaster в одном хуке, без `middleware.onError`.
          </Text>
          <UseFormServerActionDemo />
        </Box>

        {/* Отказ значением */}
        <Box>
          <Heading size="md" mb={3}>
            Отказ Server Action значением — ActionFailure
          </Heading>
          <Text fontSize="sm" color="fg.muted" mb={3}>
            В production Next.js стирает текст ошибки, брошенной из Server Action. Ожидаемый отказ сервер возвращает
            значением (<Code>actionFailure</Code> / <Code>catchActionFailure</Code>), а форма бросает его обратно.
          </Text>
          <ActionFailureDemo />
        </Box>

        {/* Поддерживаемые форматы */}
        <Box>
          <Heading size="md" mb={3}>
            Поддерживаемые форматы
          </Heading>
          <VStack align="start" gap={2} fontSize="sm">
            <Text>
              <strong>Prisma P2002</strong> — unique constraint → маппинг meta.target на поле
            </Text>
            <Text>
              <strong>Prisma P2003</strong> — foreign key → маппинг meta.field_name
            </Text>
            <Text>
              <strong>Prisma P2025</strong> — not found → глобальная ошибка
            </Text>
            <Text>
              <strong>Prisma P2014</strong> — relation violation → "есть связанные записи"
            </Text>
            <Text>
              <strong>ZenStack policy</strong> — rejected-by-policy → "Нет доступа"
            </Text>
            <Text>
              <strong>ZenStack db-query</strong> — оборачивает Prisma, автодетект кода
            </Text>
            <Text>
              <strong>Zod flatten</strong> — {'{ fieldErrors, formErrors }'} → прямой маппинг
            </Text>
            <Text>
              <strong>ActionResult</strong> — {'{ success: false, error }'} → строка или nested
            </Text>
            <Text>
              <strong>ActionFailure</strong> — {'{ success: false, error, field? }'} → под поле и в общий блок
            </Text>
            <Text>
              <strong>Error с .info</strong> — ZenStack стиль (.info.reason)
            </Text>
          </VStack>
        </Box>
      </VStack>
    </DemoPageLayout>
  )
}

С декларативным <Form>

Пример выше использует низкоуровневый useAppForm — там form доступен напрямую из замыкания. Декларативная обёртка (createForm() → <Form onSubmit={(data) => ...}>) устроена иначе: её onSubmit получает только data, без инстанса формы, а обработка ошибок вынесена в middleware.onError — туда попадает то, что бросил сам onSubmit/Server Action (FormSimple перехватывает исключение и вызывает middleware.onError, см. libs/forms/src/lib/declarative/form-root/form-simple.tsx).

Чтобы применить mapServerErrors/applyServerErrors в этом случае, нужен доступ к инстансу формы снаружи onSubmit — для этого useFormRef():

import { applyServerErrors, mapServerErrors, useFormRef } from '@letar/forms'

function MaterialForm() {
  const formRef = useFormRef()

  async function handleSubmit(data: MaterialFormData) {
    // Server Action просто бросает ошибку (Prisma/ZenStack/Error) — оборачивать вручную не нужно
    await createMaterial(data)
  }

  return (
    <Form
      schema={MaterialSchema}
      initialValue={initialValue}
      onSubmit={handleSubmit}
      formRef={formRef}
      middleware={{
        onError: (error) => {
          const mapped = mapServerErrors(error, {
            fieldMap: { sku: { field: 'sku', message: 'Такой артикул уже используется' } },
          })
          if (formRef.current) {
            applyServerErrors(formRef.current, mapped)
          }
        },
      }}
    >
      <Form.Errors />
      <Form.Field.String name="sku" />
      <Form.Button.Submit>Сохранить</Form.Button.Submit>
    </Form>
  )
}

Ключевые моменты:

  • formRef передаётся в <Form> и используется внутри middleware.onError — без него applyServerErrors некуда применять ошибки.
  • Server Action, вызываемый из onSubmit, не должен ловить и оборачивать ошибку сам — mapServerErrors умеет разбирать «сырые» Prisma/ZenStack/Zod исключения.
  • Не забудь <Form.Errors /> в JSX — иначе formErrors (например P2025/rejected-by-policy) будут применены к форме, но нигде не отрисуются.
  • Рабочий пример — apps/domwellbes/src/app/(admin)/admin/materials/_components/material-form.tsx.

useFormServerAction — та же связка в один вызов

Секции выше показывают полный контроль над formRef + middleware.onError + mapServerErrors/applyServerErrors — нужен, когда обработка ошибки различается по её типу. Для типового случая (просто применить ошибку к полям + показать pending + опциональный toast) useFormServerAction (@letar/forms-react) снимает эту ceremony одним хуком:

import { Form, useFormRef, useFormServerAction } from '@letar/forms'

function SignupForm() {
  const formRef = useFormRef()
  const { run, pending } = useFormServerAction(formRef, {
    fieldMap: { email: { field: 'email', message: 'Этот email уже зарегистрирован' } },
    toaster,
    successMessage: 'Пользователь создан',
  })

  return (
    <Form schema={SignupSchema} initialValue={{ email: '' }} formRef={formRef}
      onSubmit={async (data) => {
        await run(() => createUser(data))
      }}
    >
      <Form.Errors />
      <Form.Field.String name="email" />
      <Form.Button.Submit loadingText="Отправка...">Создать</Form.Button.Submit>
    </Form>
  )
}
  • run(action, onSuccess?) вызывает action; при ошибке применяет mapServerErrors/ applyServerErrors и показывает toast, а затем перебрасывает исходную ошибку — это обязательно, не косметика: если бы run глотала ошибку, <Form> посчитал бы сабмит успешным и своим post-submit reset() стёр бы только что применённые field-level ошибки раньше, чем пользователь успел бы их увидеть. try/catch в onSubmit всё равно не нужен — <Form> сам ловит исключение из onSubmit в том же месте, где уже ловит throw из middleware.onError.
  • При ошибке — тот же mapServerErrors + applyServerErrors(formRef.current, mapped), что и выше, плюс toaster.create({ type: 'error', ... }), если toaster передан (текст берётся из formErrors, иначе из первого fieldErrors, иначе defaultMessage).
  • При успехе — опциональный toaster.create({ type: 'success', title: successMessage }) и вызов onSuccess?.(result) со значением, которое вернул action.
  • pending — булево состояние на время выполнения action, для кастомного индикатора рядом с <Form.Button.Submit loadingText="...">.
  • Низкоуровневый путь (middleware.onError из раздела выше) остаётся рабочим — берите его, если нужно разное поведение по типу ошибки (например не показывать toast на одних ошибках и показывать на других).

Отказ Server Action значением — ActionFailure

⚠️ В production Next.js стирает текст любой ошибки, брошенной из Server Action — клиент видит «Minified React error» (код 441) вместо причины. Поэтому ожидаемый отказ (бизнес-правило, дубль уникального значения) сервер возвращает значением, а форма на клиенте превращает его обратно в исключение — клиентский throw не стирается. Настоящие неполадки по-прежнему бросаются: их текст пользователю не нужен, он идёт в логи и трекер ошибок.

Сервер (импорт из @letar/forms/server-errors — без React и Chakra):

'use server'
import { catchActionFailure, UserFacingError } from '@letar/forms/server-errors'

export async function createCategory(input: CategoryInput) {
  return catchActionFailure(async () => {
    if (await isNameReserved(input.name)) {
      throw new UserFacingError('Это название зарезервировано', 'name') // поле — необязательно
    }
    return db.category.create({ data: input, select: { id: true } })
  }, {
    // свои тексты при дубле: ключ — поле или поля через «_» для составного ограничения
    uniqueMessages: { slug: 'Такой адрес уже занят — задайте другой' },
  })
}

catchActionFailure ловит только UserFacingError и нарушение unique (SQLSTATE 23505); остальное пробрасывает. Вернуть отказ напрямую: return actionFailure('Нельзя удалить', 'id').

Клиент — два пути, оба кладут причину под поле и в <Form.Errors />:

// 1. useFormServerAction: run сам узнаёт возвращённый отказ и бросает ActionFailureError
const { run, pending } = useFormServerAction(formRef, { toaster })
await run(() => createCategory(data), () => router.push('/admin/categories/'))

// 2. Низкоуровневый путь: unwrapActionResult + middleware.onError
const { formRef, middleware } = useActionFormErrors()
<MyForm formRef={formRef} middleware={middleware}
  onSubmit={async (data) => { unwrapActionResult(await createCategory(data)) }}>
  <MyForm.Errors />

При отказе onSuccess и тост успеха не вызываются.

Маркер. Отказ — это { success: false, error: string, field?: string }. Явный success: false важен: успешный результат с полем error ({ items, error: 'часть строк пропущена' }) отказом не считается. Собирай значение через actionFailure(error, field?), а не руками.

На какое поле ляжет дубль. Prisma называет ограничение <Table>_<field>_key. Поле выводится только когда имя однозначно — три части (MaterialCategory_slug_key → slug). Составной ключ, таблица с @@map("snake_case") или колонка с @map поля не дадут: _ не отличает их от подчёркивания внутри имени, а ошибка под чужим полем хуже общего сообщения. Тогда текст берётся из uniqueMessages (ключ сверяется с хвостом имени ограничения) либо общий.

Поддерживаемые форматы

ФорматПримерРезультат
Prisma P2002{ code: 'P2002', meta: { target: ['email'] } }fieldErrors: [{ field: 'email' }]
Prisma P2003{ code: 'P2003', meta: { field_name: 'categoryId' } }fieldErrors: [{ field: 'categoryId' }]
Prisma P2025{ code: 'P2025' }formErrors: ['Запись не найдена']
ZenStack policy{ reason: 'rejected-by-policy' }formErrors: ['Нет доступа']
ZenStack db-query{ reason: 'db-query-error', code: 'P2002', meta }Делегация Prisma
Zod flatten{ fieldErrors: { email: ['msg'] } }Прямой маппинг
ActionResult{ success: false, error: 'msg' }formErrors: ['msg']
ActionFailure{ success: false, error: 'msg', field: 'slug' }fieldErrors + formErrors

API

mapServerErrors(error, config?)

const mapped = mapServerErrors(error, {
  fieldMap: Record<string, { field: string; message: string }>,
  format: 'auto' | 'prisma' | 'zenstack' | 'zod' | 'action-result',
  locale: 'ru' | 'en',
  defaultMessage: 'Произошла ошибка',
})

applyServerErrors(form, mapped)

Применяет ошибки к TanStack Form через form.setFieldMeta и form.setErrorMap.

Импорт

import { applyServerErrors, mapServerErrors } from '@letar/forms'
import { mapServerErrors } from '@letar/forms/server-errors'
import { parsePrismaError, parseZenStackError } from '@letar/forms/server-errors'

// Отказ Server Action значением: сервер — без React, клиент — хуки
import { actionFailure, catchActionFailure, unwrapActionResult, UserFacingError } from '@letar/forms/server-errors'
import { useActionFormErrors, useFormServerAction } from '@letar/forms'

On this page