# Forms

> Server validation errors into native form state with the fieldErrors attachment, issues() and errorKey().

Page: https://svelte-lean.vercel.app/query/forms · Markdown: https://svelte-lean.vercel.app/query/forms.md · Index: https://svelte-lean.vercel.app/llms.txt

A failed save carries typed field errors; one attachment puts them into the form's native state and removes them again.

## Field errors

`src/routes/members/[id]/edit/+page.svelte`

```svelte
<!-- src/routes/members/[id]/edit/+page.svelte -->
<script lang="ts">
	import { createMutation } from '@svelte-lean/query';
	import { fieldErrors, issues } from '@svelte-lean/query/forms';
	import { updateMember } from '$api/members';
	import type { Member } from '$api/types';
	import { messageOf } from './messages';

	let { member }: { member: Member } = $props();

	const save = createMutation(updateMember);

	function submit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
		event.preventDefault();
		const form = new FormData(event.currentTarget);
		save.run({ id: member.id, name: String(form.get('name')), email: String(form.get('email')) });
	}
</script>

<form {@attach fieldErrors(() => save.error)} onsubmit={submit} novalidate>
	<div data-slean="field">
		<label for="member-name">Name</label>
		<input id="member-name" name="name" value={member.name} required />
		<p id="member-name-error" data-slean-part="error">{issues(save.error, 'name').join(' ')}</p>
	</div>
	<div data-slean="field">
		<label for="member-email">Email</label>
		<input id="member-email" name="email" type="email" value={member.email} required />
		<p id="member-email-error" data-slean-part="error">{issues(save.error, 'email').join(' ')}</p>
	</div>

	{#if save.error && save.error.kind !== 'validation'}
		<p role="alert">{messageOf(save.error)}</p>
	{/if}
	<button disabled={save.busy}>Save</button>
</form>
```

The form keeps its own values. A `422` answer (or a refusal with a field map) becomes `save.error` with `kind: 'validation'`; the attachment reads it and writes:

| Written | Where |
| --- | --- |
| `aria-invalid="true"` | on each control a server message names |
| `aria-describedby` | the id of the field’s error part, added to the ids already there |
| `setCustomValidity()` | the first message, so :invalid styles and the validation bubble apply |
| `focus` | the first invalid control, unless the user is typing in another one |

The error part is the `data-slean-part="error"` element inside the same `data-slean="field"`. Editing a marked control removes exactly what was written and hides its outdated message; a new error, `reset()` or a form reset clears every mark.

## Messages

`issues(error, path)` returns the messages of one field: the exact key, then its wildcard form, so `issues(error, 'lines.0.sku')` also finds `lines.*.sku`. Without a path it returns every issue in server order.

The error is typed by the request body: `save.error?.fields?.fields.email` exists only when the body has `email`, and `fields.at('lines', 0, 'sku')` walks nested keys.

## Translation

`src/lib/messages.ts`

```ts
// Translating errors: errorKey() gives a stable reason; the words are the application's.
import type { QueryError } from '@svelte-lean/query';
import { errorKey, type ErrorKey } from '@svelte-lean/query/forms';

const MESSAGES: Record<ErrorKey, string> = {
	network: 'You are offline. Try again when the connection is back.',
	timeout: 'The server took too long to answer.',
	server: 'Something failed on the server.',
	'rate-limited': 'Too many requests. Wait a moment.',
	'not-found': 'This item no longer exists.',
	conflict: 'Someone else changed this item. Reload it.',
	http: 'The request failed.',
	forbidden: 'You do not have access to this.',
	validation: 'Check the marked fields.',
	refused: 'The server refused the change.',
	parse: 'The answer could not be read.'
};

export const messageOf = (error: QueryError) => error.message || MESSAGES[errorKey(error)];
```

The package shows no text of its own. `errorKey()` turns an error into a stable reason (`http` errors become `server`, `rate-limited`, `not-found` or `conflict` by status); the words come from the application.

## Options

`fieldErrors options`

```ts
fieldErrors(error, {
	name: (path) => path.replace(/^address\./, 'billing_'), // server path → control name
	text: (message) => t(message), // translate field messages
	unmatched: (rest) => showBanner(rest.map((issue) => issue.message)), // no control for these
	focus: true, // focus the first invalid control (default)
	validity: true // setCustomValidity(first message) (default)
});
```

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `name` | `(path) => string` | dot path, bracket form, then name\[\] | Maps a server path to a control name. |
| `text` | `(message, path) => string` | identity | Translates field messages. |
| `unmatched` | `(issues) => void` | – | Receives the issues with no control in this form. |
| `focus` | `boolean` | true | Focus the first invalid control. |
| `validity` | `boolean` | true | Set custom validity on each marked control. |

## Guarantees

- Validation errors and refusals are never retried.
- The attachment writes attributes and custom validity only; no element is created.
- A message that looks like an untranslated key (`validation.required`) is reported in development by [devtools](https://svelte-lean.vercel.app/query/devtools.md).

## Reference

- [@svelte-lean/query/forms](https://svelte-lean.vercel.app/query/api/forms.md)
- [@svelte-lean/query/laravel](https://svelte-lean.vercel.app/query/api/laravel.md): how a 422 answer is read
- [Field primitive](https://svelte-lean.vercel.app/primitives/field.md): the label, hint and error markup
