Skip to content

Commit c2d54b5

Browse files
feat: imperative form validation (#14624)
* feat: imperative form validation Not all validation can happen via the schema, this introduces a way to do it imperatively * fix * Apply suggestion from @Rich-Harris Co-authored-by: Rich Harris <[email protected]> * Apply suggestion from @Rich-Harris Co-authored-by: Rich Harris <[email protected]> * tweak `invalid(...)` API (#14625) * Apply suggestions from code review --------- Co-authored-by: Rich Harris <[email protected]>
1 parent c13de51 commit c2d54b5

File tree

9 files changed

+351
-52
lines changed

9 files changed

+351
-52
lines changed

.changeset/all-tables-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: imperative form validation

documentation/docs/20-core-concepts/60-remote-functions.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,43 @@ Alternatively, you could use `select` and `select multiple`:
450450
451451
> [!NOTE] As with unchecked `checkbox` inputs, if no selections are made then the data will be `undefined`. For this reason, the `languages` field uses `v.optional(v.array(...), [])` rather than just `v.array(...)`.
452452
453+
### Programmatic validation
454+
455+
In addition to declarative schema validation, you can programmatically mark fields as invalid inside the form handler using the `invalid` function. This is useful for cases where you can't know if something is valid until you try to perform some action:
456+
457+
```js
458+
/// file: src/routes/shop/data.remote.js
459+
import * as v from 'valibot';
460+
import { form } from '$app/server';
461+
import * as db from '$lib/server/database';
462+
463+
export const buyHotcakes = form(
464+
v.object({
465+
qty: v.pipe(
466+
v.number(),
467+
v.minValue(1, 'you must buy at least one hotcake')
468+
)
469+
}),
470+
async (data, invalid) => {
471+
try {
472+
await db.buy(data.qty);
473+
} catch (e) {
474+
if (e.code === 'OUT_OF_STOCK') {
475+
invalid(
476+
invalid.qty(`we don't have enough hotcakes`)
477+
);
478+
}
479+
}
480+
}
481+
);
482+
```
483+
484+
The `invalid` function works as both a function and a proxy:
485+
486+
- Call `invalid(issue1, issue2, ...issueN)` to throw a validation error
487+
- If an issue is a `string`, it applies to the form as a whole (and will show up in `fields.allIssues()`)
488+
- Use `invalid.fieldName(message)` to create an issue for a specific field. Like `fields` this is type-safe and you can use regular property access syntax to create issues for deeply nested objects (e.g. `invalid.profile.email('Email already exists')` or `invalid.items[0].qty('Insufficient stock')`)
489+
453490
### Validation
454491
455492
If the submitted data doesn't pass the schema, the callback will not run. Instead, each invalid field's `issues()` method will return an array of `{ message: string }` objects, and the `aria-invalid` attribute (returned from `as(...)`) will be set to `true`:

packages/kit/src/exports/public.d.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1925,7 +1925,7 @@ type RemoteFormFields<T> =
19251925
: RemoteFormFieldContainer<T> & { [K in keyof T]-?: RemoteFormFields<T[K]> };
19261926

19271927
// By breaking this out into its own type, we avoid the TS recursion depth limit
1928-
type RecursiveFormFields = RemoteFormField<any> & { [key: string]: RecursiveFormFields };
1928+
type RecursiveFormFields = RemoteFormField<any> & { [key: string | number]: RecursiveFormFields };
19291929

19301930
type MaybeArray<T> = T | T[];
19311931

@@ -1945,6 +1945,49 @@ type ExtractId<Input> = Input extends { id: infer Id }
19451945
: string | number
19461946
: string | number;
19471947

1948+
/**
1949+
* Recursively maps an input type to a structure where each field can create a validation issue.
1950+
* This mirrors the runtime behavior of the `invalid` proxy passed to form handlers.
1951+
*/
1952+
type InvalidField<T> =
1953+
WillRecurseIndefinitely<T> extends true
1954+
? Record<string | number, any>
1955+
: NonNullable<T> extends string | number | boolean | File
1956+
? (message: string) => StandardSchemaV1.Issue
1957+
: NonNullable<T> extends Array<infer U>
1958+
? {
1959+
[K in number]: InvalidField<U>;
1960+
} & ((message: string) => StandardSchemaV1.Issue)
1961+
: NonNullable<T> extends RemoteFormInput
1962+
? {
1963+
[K in keyof T]-?: InvalidField<T[K]>;
1964+
} & ((message: string) => StandardSchemaV1.Issue)
1965+
: Record<string, never>;
1966+
1967+
/**
1968+
* A function and proxy object used to imperatively create validation errors in form handlers.
1969+
*
1970+
* Call `invalid(issue1, issue2, ...issueN)` to throw a validation error.
1971+
* If an issue is a `string`, it applies to the form as a whole (and will show up in `fields.allIssues()`)
1972+
* Access properties to create field-specific issues: `invalid.fieldName('message')`.
1973+
* The type structure mirrors the input data structure for type-safe field access.
1974+
*
1975+
* @example
1976+
* ```ts
1977+
* invalid('Username or password is invalid');
1978+
* ```
1979+
*
1980+
* @example
1981+
* ```ts
1982+
* invalid(
1983+
* invalid.username('Username is taken'),
1984+
* invalid.items[0].qty('Insufficient stock')
1985+
* );
1986+
* ```
1987+
*/
1988+
export type Invalid<Input = any> = ((...issues: Array<string | StandardSchemaV1.Issue>) => never) &
1989+
InvalidField<Input>;
1990+
19481991
/**
19491992
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
19501993
*/

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 147 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { get_cache, run_remote_function } from './shared.js';
2020
*
2121
* @template Output
2222
* @overload
23-
* @param {() => MaybePromise<Output>} fn
23+
* @param {(invalid: import('@sveltejs/kit').Invalid<void>) => MaybePromise<Output>} fn
2424
* @returns {RemoteForm<void, Output>}
2525
* @since 2.27
2626
*/
@@ -33,7 +33,7 @@ import { get_cache, run_remote_function } from './shared.js';
3333
* @template Output
3434
* @overload
3535
* @param {'unchecked'} validate
36-
* @param {(data: Input) => MaybePromise<Output>} fn
36+
* @param {(data: Input, invalid: import('@sveltejs/kit').Invalid<Input>) => MaybePromise<Output>} fn
3737
* @returns {RemoteForm<Input, Output>}
3838
* @since 2.27
3939
*/
@@ -46,26 +46,27 @@ import { get_cache, run_remote_function } from './shared.js';
4646
* @template Output
4747
* @overload
4848
* @param {Schema} validate
49-
* @param {(data: StandardSchemaV1.InferOutput<Schema>) => MaybePromise<Output>} fn
49+
* @param {(data: StandardSchemaV1.InferOutput<Schema>, invalid: import('@sveltejs/kit').Invalid<StandardSchemaV1.InferOutput<Schema>>) => MaybePromise<Output>} fn
5050
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
5151
* @since 2.27
5252
*/
5353
/**
5454
* @template {RemoteFormInput} Input
5555
* @template Output
5656
* @param {any} validate_or_fn
57-
* @param {(data?: Input) => MaybePromise<Output>} [maybe_fn]
57+
* @param {(data_or_invalid: any, invalid?: any) => MaybePromise<Output>} [maybe_fn]
5858
* @returns {RemoteForm<Input, Output>}
5959
* @since 2.27
6060
*/
6161
/*@__NO_SIDE_EFFECTS__*/
6262
// @ts-ignore we don't want to prefix `fn` with an underscore, as that will be user-visible
6363
export function form(validate_or_fn, maybe_fn) {
64-
/** @type {(data?: Input) => Output} */
64+
/** @type {any} */
6565
const fn = maybe_fn ?? validate_or_fn;
6666

6767
/** @type {StandardSchemaV1 | null} */
68-
const schema = !maybe_fn || validate_or_fn === 'unchecked' ? null : validate_or_fn;
68+
const schema =
69+
!maybe_fn || validate_or_fn === 'unchecked' ? null : /** @type {any} */ (validate_or_fn);
6970

7071
/**
7172
* @param {string | number | boolean} [key]
@@ -152,37 +153,32 @@ export function form(validate_or_fn, maybe_fn) {
152153
}
153154

154155
if (validated?.issues !== undefined) {
155-
output.issues = flatten_issues(validated.issues);
156-
157-
// if it was a progressively-enhanced submission, we don't need
158-
// to return the input — it's already there
159-
if (!event.isRemoteRequest) {
160-
output.input = {};
161-
162-
for (let key of form_data.keys()) {
163-
// redact sensitive fields
164-
if (/^[.\]]?_/.test(key)) continue;
165-
166-
const is_array = key.endsWith('[]');
167-
const values = form_data.getAll(key).filter((value) => typeof value === 'string');
168-
169-
if (is_array) key = key.slice(0, -2);
170-
171-
output.input = set_nested_value(
172-
/** @type {Record<string, any>} */ (output.input),
173-
key,
174-
is_array ? values : values[0]
175-
);
176-
}
177-
}
156+
handle_issues(output, validated.issues, event.isRemoteRequest, form_data);
178157
} else {
179158
if (validated !== undefined) {
180159
data = validated.value;
181160
}
182161

183162
state.refreshes ??= {};
184163

185-
output.result = await run_remote_function(event, state, true, data, (d) => d, fn);
164+
const invalid = create_invalid();
165+
166+
try {
167+
output.result = await run_remote_function(
168+
event,
169+
state,
170+
true,
171+
data,
172+
(d) => d,
173+
(data) => (!maybe_fn ? fn(invalid) : fn(data, invalid))
174+
);
175+
} catch (e) {
176+
if (e instanceof ValidationError) {
177+
handle_issues(output, e.issues, event.isRemoteRequest, form_data);
178+
} else {
179+
throw e;
180+
}
181+
}
186182
}
187183

188184
// We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads
@@ -290,3 +286,124 @@ export function form(validate_or_fn, maybe_fn) {
290286

291287
return create_instance();
292288
}
289+
290+
/**
291+
* @param {{ issues?: Record<string, any>, input?: Record<string, any>, result: any }} output
292+
* @param {readonly StandardSchemaV1.Issue[]} issues
293+
* @param {boolean} is_remote_request
294+
* @param {FormData} form_data
295+
*/
296+
function handle_issues(output, issues, is_remote_request, form_data) {
297+
output.issues = flatten_issues(issues);
298+
299+
// if it was a progressively-enhanced submission, we don't need
300+
// to return the input — it's already there
301+
if (!is_remote_request) {
302+
output.input = {};
303+
304+
for (let key of form_data.keys()) {
305+
// redact sensitive fields
306+
if (/^[.\]]?_/.test(key)) continue;
307+
308+
const is_array = key.endsWith('[]');
309+
const values = form_data.getAll(key).filter((value) => typeof value === 'string');
310+
311+
if (is_array) key = key.slice(0, -2);
312+
313+
output.input = set_nested_value(
314+
/** @type {Record<string, any>} */ (output.input),
315+
key,
316+
is_array ? values : values[0]
317+
);
318+
}
319+
}
320+
}
321+
322+
/**
323+
* Creates an invalid function that can be used to imperatively mark form fields as invalid
324+
* @returns {import('@sveltejs/kit').Invalid}
325+
*/
326+
function create_invalid() {
327+
/**
328+
* @param {...(string | StandardSchemaV1.Issue)} issues
329+
* @returns {never}
330+
*/
331+
function invalid(...issues) {
332+
throw new ValidationError(
333+
issues.map((issue) => {
334+
if (typeof issue === 'string') {
335+
return {
336+
path: [],
337+
message: issue
338+
};
339+
}
340+
341+
return issue;
342+
})
343+
);
344+
}
345+
346+
return /** @type {import('@sveltejs/kit').Invalid} */ (
347+
new Proxy(invalid, {
348+
get(target, prop) {
349+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
350+
351+
/**
352+
* @param {string} message
353+
* @param {(string | number)[]} path
354+
* @returns {StandardSchemaV1.Issue}
355+
*/
356+
const create_issue = (message, path = []) => ({
357+
message,
358+
path
359+
});
360+
361+
return create_issue_proxy(prop, create_issue, []);
362+
}
363+
})
364+
);
365+
}
366+
367+
/**
368+
* Error thrown when form validation fails imperatively
369+
*/
370+
class ValidationError extends Error {
371+
/**
372+
* @param {StandardSchemaV1.Issue[]} issues
373+
*/
374+
constructor(issues) {
375+
super('Validation failed');
376+
this.name = 'ValidationError';
377+
this.issues = issues;
378+
}
379+
}
380+
381+
/**
382+
* Creates a proxy that builds up a path and returns a function to create an issue
383+
* @param {string | number} key
384+
* @param {(message: string, path: (string | number)[]) => StandardSchemaV1.Issue} create_issue
385+
* @param {(string | number)[]} path
386+
*/
387+
function create_issue_proxy(key, create_issue, path) {
388+
const new_path = [...path, key];
389+
390+
/**
391+
* @param {string} message
392+
* @returns {StandardSchemaV1.Issue}
393+
*/
394+
const issue_func = (message) => create_issue(message, new_path);
395+
396+
return new Proxy(issue_func, {
397+
get(target, prop) {
398+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
399+
400+
// Handle array access like invalid.items[0]
401+
if (/^\d+$/.test(prop)) {
402+
return create_issue_proxy(parseInt(prop, 10), create_issue, new_path);
403+
}
404+
405+
// Handle property access like invalid.field.nested
406+
return create_issue_proxy(prop, create_issue, new_path);
407+
}
408+
});
409+
}

packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import * as v from 'valibot';
44
55
const schema = v.object({
6-
foo: v.picklist(['a', 'b']),
6+
foo: v.picklist(['a', 'b', 'c']),
77
bar: v.picklist(['d', 'e']),
8-
button: v.literal('submitter')
8+
button: v.optional(v.literal('submitter'))
99
});
1010
let submitter;
1111
$inspect(my_form.fields.allIssues());
@@ -24,6 +24,8 @@
2424

2525
<input {...my_form.fields.bar.as('text')} />
2626

27+
<button>submit (imperative validation)</button>
28+
2729
<button bind:this={submitter} {...my_form.fields.button.as('submit')} value="incorrect_value">
2830
submit
2931
</button>

packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ export const my_form = form(
55
v.object({
66
foo: v.picklist(['a', 'b', 'c']),
77
bar: v.picklist(['d', 'e', 'f']),
8-
button: v.literal('submitter')
8+
button: v.optional(v.literal('submitter'))
99
}),
10-
async (data) => {
10+
async (data, invalid) => {
11+
// Test imperative validation
12+
if (data.foo === 'c') {
13+
invalid(invalid.foo('Imperative: foo cannot be c'));
14+
}
15+
1116
console.log(data);
1217
}
1318
);

packages/kit/test/apps/basics/test/test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,6 +1844,7 @@ test.describe('remote functions', () => {
18441844

18451845
const foo = page.locator('input[name="foo"]');
18461846
const bar = page.locator('input[name="bar"]');
1847+
const submit = page.locator('button:has-text("imperative validation")');
18471848

18481849
await foo.fill('a');
18491850
await expect(page.locator('form')).not.toContainText('Invalid type: Expected');
@@ -1860,6 +1861,12 @@ test.describe('remote functions', () => {
18601861
await expect(page.locator('form')).toContainText(
18611862
'Invalid type: Expected "submitter" but received "incorrect_value"'
18621863
);
1864+
1865+
// Test imperative validation
1866+
await foo.fill('c');
1867+
await bar.fill('d');
1868+
await submit.click();
1869+
await expect(page.locator('form')).toContainText('Imperative: foo cannot be c');
18631870
});
18641871

18651872
test('form inputs excludes underscore-prefixed fields', async ({ page, javaScriptEnabled }) => {

0 commit comments

Comments
 (0)