Svelte-Command-Form allows you to have easy to use forms with commands instead of remote forms. Is this redundant? Maybe. However, you may not want to use an HTML form everytime. The API is greatly influenced by SvelteKit-Superforms, so if you are used to that you shouldn't have a problem here.
- Schema-agnostic validation – Works with any library that implements the Standard Schema v1 interface (Zod, Valibot, TypeBox, custom validators, …).
- Command-first workflow – Wire forms directly to your remote command (
commandfrom$app/server), and let the helper manage submission, success, and error hooks. - Typed form state –
form,errors, andissuesare all strongly typed from your schema, so your component code stays in sync with validation rules. - Friendly + raw errors – Surface user-friendly
errorsfor rendering, while also exposing the untouched validatorissuesarray for logging/analytics. - Helpers for remote inputs – Includes
normalizeFilesfor bundling file uploads andstandardValidatefor reusing schema validation outside the form class.
pnpm add @akcodeworks/svelte-command-form
# or
npm install @akcodeworks/svelte-command-form<script lang="ts">
import { CommandForm } from 'svelte-command-form';
import { schema } from '$lib/schemas/user.schema';
import { saveUser } from '$lib/server/save-user';
const form = new CommandForm(schema, {
initial: { name: '' },
command: saveUser,
reset: 'onSuccess',
onSuccess: (result) => console.log('Saved', result)
});
</script>
<input bind:value="{form.form.name}" placeholder="Name" />
{#if form.errors.name}
<p class="error">{form.errors.name.message}</p>
{/if}
<button disabled="{form.submitting}" onclick="{form.submit}">
{form.submitting ? 'Saving…' : 'Save'}
</button>CommandForm keeps two synchronized error stores:
errors– per-field objects{ message: string }that are easy to render.issues– the untouched Standard Schema issue array (useful for logs/analytics).
To display errors in the DOM, check the keyed entry in form.errors:
<label>
Name
<input bind:value="{form.form.name}" />
</label>
{#if form.errors.name}
<p class="error">{form.errors.name.message}</p>
{/if}
<label>
Age
<input type="number" bind:value="{form.form.age}" />
</label>
{#if form.errors.age}
<p class="error">{form.errors.age.message}</p>
{/if}Running await form.validate() triggers the same schema parsing as submit() without sending data, so you can eagerly show validation feedback (e.g., on blur). Whenever validation passes, both errors and issues are cleared.
Array and nested errors follow the dot-path reported by your schema. If the schema declares names: z.array(z.string()) and the user submits [123], the error map becomes:
{
'names.0': { message: 'Expected string' }
}Render that however makes sense—either surface the aggregated message near the group (form.errors['names.0']?.message) or group entries by prefix to display per-item errors.
Any schema object that exposes the ~standard property works:
import { z } from 'zod'; // or create a schema with any StandardSchemaV1 compliant lib.
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email()
});
const form = new CommandForm(userSchema, { command: saveUser });| Option | Type | Description |
|---|---|---|
initial |
Partial<T> | () => Partial<T> |
Optional initial values. Returning a function lets you compute defaults per instance. |
command |
(input: TIn) => Promise<TOut> |
Required remote command. The resolved value is stored in result. |
invalidate |
string | string[] | 'all' |
Optional SvelteKit invalidation target(s) to refresh once a submission succeeds. |
reset |
'onSuccess' | 'always' | 'onError' |
Optional reset behavior (default: no auto reset). |
onSubmit |
(data) => void | Promise<void> |
Called right after the schema parse succeeds, before command. |
onSuccess |
(result) => void | Promise<void> |
Runs after command resolves. |
onError |
(err) => void | Promise<void> |
Runs after client, schema, or HTTP errors are handled. |
form–$stateproxy representing the form model. Bind inputs directly to its keys.errors–$statemap of{ [field]: { message } }that is ideal for user-facing feedback.issues–$state<SchemaIssues | null>storing the untouched array emitted bystandardValidate. Use this for logging or non-standard UI patterns.submitting– Boolean getter reflectingsubmit()progress.result– Getter exposing the last command result (ornull).
set(values, clear?)– Merge values into the form. Passtrueto replace instead of merge.reset()– Restore the form to its initial state.validate()– Runs schema validation without submitting, updating botherrorsandissues.submit()– Parses the schema, calls hooks, executes the configured command, manages invalidation, and populates error state on failure.getErrors()/getIssues()– Accessor helpers useful outside of$statereactivity (e.g., from tests).addError({path: string, message: string})- Allows you to set an error on the form programatically (client side only)
A small helper that runs the Standard Schema validate function, awaits async results, and throws SchemaValidationError when issues are returned. Use it to share validation logic between the form and other server utilities.
Custom error class wrapping the exact issues array returned by your schema. Catch it to reuse transformIssues or custom logging.
Utility that converts a File[] into JSON-friendly objects { name, type, size, bytes }, making it easy to send uploads through command functions.
SvelteKit command functions currently expect JSON-serializable payloads, so File objects cannot be passed directly from the client to a command. Use the provided normalizeFiles helper to convert browser File instances into serializable blobs inside the onSubmit hook (so the parsed data that reaches your command already contains normalized entries):
<script lang="ts">
import { CommandForm, normalizeFiles } from 'svelte-command-form';
import { zodSchema } from '$lib/schemas/upload.schema';
import { uploadCommand } from '$lib/server/upload.remote';
const form = new CommandForm(zodSchema, {
command: uploadCommand,
async onSubmit(data) {
data.attachments = await normalizeFiles(data.attachments);
}
});
const handleFiles = (event: Event) => {
const input = event.target as HTMLInputElement;
form.set({ attachments: input.files ? [...input.files] : [] });
};
</script>
<input type="file" multiple onchange="{handleFiles}" />normalizeFiles outputs objects like:
type NormalizedFile = {
name: string;
type: string;
size: number;
bytes: Uint8Array;
};Both the Zod and Valibot schemas above can be adapted to accept either File[] (for client-side validation) or this normalized structure if you prefer validating the serialized payload on the server.
Standard Schema v1 does not provide a cross-library location for default values. A Zod or Valibot schema may specify defaults internally, but those defaults are not discoverable through the shared ~standard interface. If there is an easy way to do this feel free to submit a PR. Because of that, CommandForm cannot pull defaults from your schema automatically. Instead, pass defaults via options.initial:
const form = new CommandForm(userSchema, {
initial: { name: 'Ada Lovelace', age: 30, attachments: [] },
command: saveUser
});initial can also be a function if you need to recompute defaults per instantiation (initial: () => ({ createdAt: new Date().toISOString() })) or if you are using a $derived(). Any keys not provided remain undefined (or null if you explicitly set them) until the user interacts with them or you call form.set. If your schema rejects undefined/null, make it nullable (z.string().nullable(), z.array(...).optional(), etc.) or seed the field via initial.
When validation fails, CommandForm:
- Throws/catches
SchemaValidationErrorfromstandardValidate. - Converts issues into
errors(per field) viatransformIssues. - Stores the raw issue array in
issuesfor programmatic access.
If the command throws an HttpError from SvelteKit, the helper looks for err.body.issues and merges them into the same structures. Any other error is forwarded to onError after clearing submission state. You can handle validation errors to populate this in your hooks.server.ts
You can add errors manually by using the addErrors method on the client or by throwing a new SchemaValidationError inside of the remote function.
// server add error
const someFunc = command(schema, async (data) => {
const user = await db.find({where: email: data.email})
if(!user) throw new SchemaValidationError([{ message: "User does with this email does not exist!", path: ['email'] }])
})<!-- +page.svelte -->
<script lang="ts">
const form = new CommandForm(schema, {
command: someCommand
});
function addError() {
form.addError({ path: 'name', message: 'Test Error' });
}
</script>
<button onclick="{addError}">Add an Error</button>Feel free to contribute by opening a PR with a detailed description of why you are wanting to change what you are changing. If it can be tested with Vitest, that is preferred.