ShunichirouKamino/svelte-ssv(opens in a new tab)
Language: Svelte
Svelte Simple Form Validation — A lightweight Zod-based form validation library
- stars
- 14
- watchers
- 0
- forks
- 0
- open issues
- 3
- Last updated
- 10/29/2026
Language: Svelte
Svelte Simple Form Validation — A lightweight Zod-based form validation library
SSV (Svelte Simple Validation) — A lightweight, validation-library agnostic form validation utility for Svelte/SvelteKit.
Demo: https://shunichiroukamino.github.io/svelte-ssv/
Svelte 5's $state, bind:value, and SvelteKit's use:enhance already cover 90% of form management that React developers need react-hook-form for. The one missing piece is converting validation errors into field-indexed errors — and that's exactly what ssv does, as a lightweight utility.
With Zod alone, handling blur-time field validation requires manual issue filtering, immutable merging, and touched/dirty state management:
// ❌ Zod direct: blur handler for a single field function handleBlur(field) { touched[field] = true; dirty[field] = formData[field] !== initial[field]; const result = schema.safeParse(formData); if (result.success) { errors = { ...errors }; delete errors[field]; } else { const fieldIssues = result.error.issues.filter(i => i.path[0] === field); errors = { ...errors }; if (fieldIssues.length > 0) { errors[field] = fieldIssues.map(i => i.message); } else { delete errors[field]; } } }
With ssv's createForm, the same behavior is a single line:
<script> import { createForm } from '@svelte-ssv/core/form'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email format'), }); let form = $state(createForm(schema, { name: '', email: '' })); </script> <!-- ✅ ssv: blur, touched, dirty, errors — all handled --> <input bind:value={form.data.name} onblur={() => form.blur('name')} /> {#if form.touched.name && form.errors.name} <p class="error">{form.errors.name[0]}</p> {/if} {#if form.isDirty} <p>You have unsaved changes.</p> {/if} <button onclick={() => form.reset()} disabled={!form.isDirty}>Reset</button>
| Concern | Zod direct | ssv |
|---|---|---|
| Full form validation → field errors | Manual issue-to-field conversion | validate() |
| Per-field validation on blur | Full safeParse + manual filtering (.refine() breaks with .pick()) |
validateField() |
| Immutable error merging | Spread + delete boilerplate each time | mergeFieldErrors() |
| Server error formatting | Build { _form: [msg] } manually |
setServerError() |
| Touched / dirty tracking | Multiple $state declarations + manual blur handler |
Built into createForm() |
| CRUD data population | Manual state reset + dirty baseline management | form.populate() |
Input wiring (value, blur, aria-invalid) |
bind:value + onblur + attribute per input |
{@attach form.field()} |
SvelteKit use:enhance integration |
cancel/update/result.type branching each form | createEnhanceHandler() / buildEnhanceHandler() |
SvelteKit's remote functions form() now covers most of what ssv does on the client (preflight validation, field issues, touched/dirty, aria-invalid). It is still experimental (you must enable both experimental.remoteFunctions and Svelte's compilerOptions.experimental.async), SvelteKit-only, and needs a server runtime because forms submit to a server endpoint. So:
| Your situation | Use |
|---|---|
| SvelteKit with a server, and you can enable the experimental flags above | Remote form() |
SvelteKit with stable form actions + use:enhance |
ssv (createForm + buildEnhanceHandler) |
| SvelteKit SPA / static build without a server | ssv |
| Plain Svelte SPA (no SvelteKit) | ssv |
| Same schema validation on server and client, outside SvelteKit | ssv (createFormValidator is framework-agnostic) |
| ssv | Remote form() |
Difference |
|---|---|---|
buildEnhanceHandler (client validation) |
.preflight(schema) |
|
form.errors.x |
fields.x.issues() |
|
form.touched.x |
fields.x.touched() |
Kit sets it on focusout, ssv on blur() |
form.dirty.x |
fields.x.dirty() |
ssv: value differs from the baseline (back to false when reverted). Kit: edited at least once since mount (stays true) |
form.validate() |
myForm.validate({ all: true }) |
Kit's is async, returns void, and calls the server when there is no .preflight() schema. ssv's is sync and returns the result |
form.populate(data) (edit dialogs) |
{...fields.x.as('text', value)} |
Kit's second argument sets the element's default value (used on reset). fields.set() replaces the input but does not reset touched/dirty |
{@attach form.field('x')} |
{...fields.x.as('text')} |
Kit's spread props are server-rendered; field() is client-only |
touched() / dirty() are present in the 3.1.0 type definitions but not yet described in the docs.
See ADR: Positioning vs. remote functions.
createFormValidator is pure TypeScript with zero framework dependenciescreateForm bundles data, errors, touched, dirty, and isDirty into one reactive object{@attach form.field('name')} wires value sync, blur validation, and aria-invalid (Svelte 5.29+)form.populate(data) seeds form data with a clean dirty baseline, ideal for edit dialogs and step forms@svelte-ssv/core/enhance reduces use:enhance boilerplate to a single attributessv accepts any schema implementing Standard Schema V1 or Zod's safeParse interface:
| Library | Supported | Via |
|---|---|---|
| Zod v4 | ✅ | Standard Schema V1 + safeParse |
| Zod v3 | ✅ | safeParse (backward compatible) |
| Valibot v1+ | ✅ | Standard Schema V1 |
| ArkType | ✅ | Standard Schema V1 |
| TypeBox | ✅ | Standard Schema V1 |
npm install @svelte-ssv/core
# or via JSR (Deno / Bun / Node.js)
npx jsr add @svelte-ssv/coreimport { createFormValidator } from '@svelte-ssv/core'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email format'), }); const validator = createFormValidator(schema); const result = validator.validate({ name: '', email: 'bad' }); // → { valid: false, data: undefined, errors: { name: ['Name is required'], email: ['Invalid email format'] } }
import { createFormValidator } from '@svelte-ssv/core'; import * as v from 'valibot'; const schema = v.object({ name: v.pipe(v.string(), v.nonEmpty('Name is required')), email: v.pipe(v.string(), v.email('Invalid email format')), }); const validator = createFormValidator(schema); // Same API — validate(), validateField(), mergeFieldErrors(), etc.
import { createFormValidator } from '@svelte-ssv/core'; import { type } from 'arktype'; const schema = type({ email: 'string.email', password: 'string >= 8', }); const validator = createFormValidator(schema);
<script> import { createForm } from '@svelte-ssv/core/form'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email format'), }); let form = $state(createForm(schema, { name: '', email: '' })); function handleSubmit(e) { e.preventDefault(); const result = form.validate(); // marks all fields touched if (!result.valid) return; // submit result.data } </script> <form onsubmit={handleSubmit} novalidate> <input bind:value={form.data.name} onblur={() => form.blur('name')} /> {#if form.touched.name && form.errors.name} <p class="error">{form.errors.name[0]}</p> {/if} <input bind:value={form.data.email} onblur={() => form.blur('email')} /> {#if form.touched.email && form.errors.email} <p class="error">{form.errors.email[0]}</p> {/if} <button type="submit">Submit</button> <button type="button" onclick={() => form.reset()} disabled={!form.isDirty}>Reset</button> </form>
With Svelte 5.29+ attachments, form.field(name) replaces bind:value + onblur:
<input {@attach form.field('name')} /> {#if form.touched.name && form.errors.name} <p class="error">{form.errors.name[0]}</p> {/if} <style> /* aria-invalid is set at runtime, so mark the selector :global */ input:global([aria-invalid='true']) { border-color: red; } </style>
The attachment:
form.data[name] both ways (including after reset() / populate())form.blur(name) on bluraria-invalid="true" while the field is touched and invalidform.data)<select> options are rendered later (e.g. loaded asynchronously)It does not set name. Write name yourself when the form is submitted natively or with use:enhance.
Supported elements:
| Element | form.data[name] |
|---|---|
text-like <input>, <textarea> |
string |
type="number" / "range" |
number, empty → null (same as bind:value) |
type="checkbox" |
boolean |
type="radio", single <select> |
the selected value; converted to a number when the field currently holds a number |
Not supported — keep using bind:value / bind:group + onblur: checkbox groups (array values; field() throws), <select multiple>, file inputs, custom components.
Requirements and limitations:
$state(), and field must be called as form.field(...) (not destructured). Otherwise values are not re-synced after reset() / populate() and aria-invalid does not update.bind:value for that input.Using buildEnhanceHandler with createForm (recommended):
<script> import { createForm } from '@svelte-ssv/core/form'; import { buildEnhanceHandler } from '@svelte-ssv/core/enhance'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email format'), }); let form = $state(createForm(schema, { name: '', email: '' })); const handleEnhance = buildEnhanceHandler(form, { onSuccess: () => closeDialog(), }); </script> <form method="POST" action="?/create" novalidate use:enhance={handleEnhance}> <input bind:value={form.data.name} onblur={() => form.blur('name')} /> {#if form.touched.name && form.errors.name}<p class="error">{form.errors.name[0]}</p>{/if} <input bind:value={form.data.email} onblur={() => form.blur('email')} /> {#if form.touched.email && form.errors.email}<p class="error">{form.errors.email[0]}</p>{/if} <button type="submit">Submit</button> </form>
createEnhanceHandler with manual wiring
<script> import { createFormValidator, type FormErrors } from '@svelte-ssv/core'; import { createEnhanceHandler } from '@svelte-ssv/core/enhance'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email format'), }); const validator = createFormValidator(schema); let formData = $state({ name: '', email: '' }); let errors = $state<FormErrors<typeof formData>>({}); function handleBlur(field: keyof typeof formData) { const result = validator.validateField(field, formData); errors = validator.mergeFieldErrors(errors, field, result); } const handleEnhance = createEnhanceHandler(validator, { getData: () => formData, setErrors: (e) => { errors = e }, onSuccess: () => closeDialog(), }); </script> <form method="POST" action="?/create" novalidate use:enhance={handleEnhance}> <input bind:value={formData.name} onblur={() => handleBlur('name')} /> {#if errors.name}<p class="error">{errors.name[0]}</p>{/if} <input bind:value={formData.email} onblur={() => handleBlur('email')} /> {#if errors.email}<p class="error">{errors.email[0]}</p>{/if} <button type="submit">Submit</button> </form>
Creates a form validator from any supported schema. Returns a FormValidator<T> with the following methods:
| Method | Description |
|---|---|
validate(data) |
Validate the entire form. Returns { valid, data, errors } |
validateField(field, data) |
Validate a single field (for onblur/oninput). Returns { errors } |
mergeFieldErrors(current, field, result) |
Merge field validation results into existing errors (immutable) |
parseErrors(issues) |
Convert validation issues into field-indexed FormErrors<T> |
setServerError(message) |
Create a form-level error ({ _form: [message] }) |
Creates a unified form state with touched/dirty tracking. Wrap in $state() for Svelte 5 reactivity.
import { createForm } from '@svelte-ssv/core/form'; let form = $state(createForm(schema, { name: '', email: '' })); // form.data — current form data (mutable, bind-friendly) // form.errors — current validation errors // form.touched — per-field touched state (set on blur) // form.dirty — per-field dirty state (differs from initial) // form.isDirty — true if any field is dirty // form.validator — the underlying FormValidator // form.blur(field) — mark touched + validate field // form.validate() — validate all + mark all touched // form.reset() — restore initial state // form.populate(data) — set data + clear errors/touched/dirty + update baseline // form.field(name) — Svelte attachment: value sync + blur + aria-invalid
Generates a callback for SvelteKit's use:enhance directive from a Form instance. Automatically wires getData, setErrors, and validator.
| Option | Type | Description |
|---|---|---|
onSuccess? |
() => void |
Called on successful server response |
onBeforeSubmit? |
() => boolean | void |
Pre-submit hook. Return false to cancel |
onAfterSubmit? |
() => void |
Called after submission regardless of outcome |
Lower-level API for manual wiring. Use buildEnhanceHandler when working with createForm.
| Option | Type | Description |
|---|---|---|
getData |
() => T |
Returns the current form data |
setErrors |
(errors: FormErrors<T>) => void |
Updates the error state |
onSuccess? |
() => void |
Called on successful server response |
onBeforeSubmit? |
() => boolean | void |
Pre-submit hook. Return false to cancel |
onAfterSubmit? |
() => void |
Called after submission regardless of outcome |
Thin wrapper, thick platform. ssv does one thing: convert validation results into field-indexed errors. Everything else is handled by the platform:
| Concern | Handled by |
|---|---|
| Form state management | Svelte 5's $state |
| Form submission | SvelteKit's use:enhance or plain fetch |
| Server validation | Schema library + SvelteKit Form Actions |
| Error display | {#if errors.field} in templates |
| Validation error → field error conversion | ssv |
# Run tests npx vitest # Run tests in watch mode npx vitest --watch
form.populate(), buildEnhanceHandler() sugar syntax{@attach form.field()} to replace bind:value + onblurform()MIT