Skip to content

Commit bd55128

Browse files
breaking: invalid now must be imported from @sveltejs/kit (#14768)
* breaking: `invalid` now must be imported from `@sveltejs/kit` TypeScript kinda forced our hand here - due to limitations of control flow analysis it does not detect the `never` return type for anything else than functions that are used directly (i.e. passing a function as a parameter doesn't work unless you explicitly type it); see microsoft/TypeScript#36753 for more info. This therefore changes `invalid` to be a function that you import just like `redirect` or `error`. A nice benefit of this is that you'll no longer have to use the second parameter passed to remote form functions to construct the list of issues in case you want to create an issue for the whole form and not just a specific field. Closes #14745 * docs * fix test * Apply suggestions from code review * Update packages/kit/src/runtime/app/server/remote/form.js * Update documentation/docs/20-core-concepts/60-remote-functions.md * prettier * regenerate * fix --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent af6bb83 commit bd55128

File tree

9 files changed

+254
-163
lines changed

9 files changed

+254
-163
lines changed

.changeset/icy-glasses-agree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
breaking: `invalid` now must be imported from `@sveltejs/kit`

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,12 @@ Alternatively, you could use `select` and `select multiple`:
454454
455455
### Programmatic validation
456456
457-
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:
457+
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. Just like `redirect` or `error`, `invalid` throws. It expects a list of strings (for issues relating to the form as a whole) or standard-schema-compliant issues (for those relating to a specific field). Use the `issue` parameter for type-safe creation of such issues:
458458
459459
```js
460460
/// file: src/routes/shop/data.remote.js
461461
import * as v from 'valibot';
462+
import { invalid } from '@sveltejs/kit';
462463
import { form } from '$app/server';
463464
import * as db from '$lib/server/database';
464465

@@ -469,13 +470,13 @@ export const buyHotcakes = form(
469470
v.minValue(1, 'you must buy at least one hotcake')
470471
)
471472
}),
472-
async (data, invalid) => {
473+
async (data, issue) => {
473474
try {
474475
await db.buy(data.qty);
475476
} catch (e) {
476477
if (e.code === 'OUT_OF_STOCK') {
477478
invalid(
478-
invalid.qty(`we don't have enough hotcakes`)
479+
issue.qty(`we don't have enough hotcakes`)
479480
);
480481
}
481482
}

packages/kit/src/exports/index.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { HttpError, Redirect, ActionFailure } from './internal/index.js';
1+
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2+
3+
import { HttpError, Redirect, ActionFailure, ValidationError } from './internal/index.js';
24
import { BROWSER, DEV } from 'esm-env';
35
import {
46
add_data_suffix,
@@ -215,6 +217,49 @@ export function isActionFailure(e) {
215217
return e instanceof ActionFailure;
216218
}
217219

220+
/**
221+
* Use this to throw a validation error to imperatively fail form validation.
222+
* Can be used in combination with `issue` passed to form actions to create field-specific issues.
223+
*
224+
* @example
225+
* ```ts
226+
* import { invalid } from '@sveltejs/kit';
227+
* import { form } from '$app/server';
228+
* import { tryLogin } from '$lib/server/auth';
229+
* import * as v from 'valibot';
230+
*
231+
* export const login = form(
232+
* v.object({ name: v.string(), _password: v.string() }),
233+
* async ({ name, _password }) => {
234+
* const success = tryLogin(name, _password);
235+
* if (!success) {
236+
* invalid('Incorrect username or password');
237+
* }
238+
*
239+
* // ...
240+
* }
241+
* );
242+
* ```
243+
* @param {...(StandardSchemaV1.Issue | string)} issues
244+
* @returns {never}
245+
* @since 2.47.3
246+
*/
247+
export function invalid(...issues) {
248+
throw new ValidationError(
249+
issues.map((issue) => (typeof issue === 'string' ? { message: issue } : issue))
250+
);
251+
}
252+
253+
/**
254+
* Checks whether this is an validation error thrown by {@link invalid}.
255+
* @param {unknown} e The object to check.
256+
* @return {e is import('./public.js').ActionFailure}
257+
* @since 2.47.3
258+
*/
259+
export function isValidationError(e) {
260+
return e instanceof ValidationError;
261+
}
262+
218263
/**
219264
* Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname.
220265
* Returns the normalized URL as well as a method for adding the potential suffix back

packages/kit/src/exports/internal/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2+
13
export class HttpError {
24
/**
35
* @param {number} status
@@ -62,4 +64,18 @@ export class ActionFailure {
6264
}
6365
}
6466

67+
/**
68+
* Error thrown when form validation fails imperatively
69+
*/
70+
export class ValidationError extends Error {
71+
/**
72+
* @param {StandardSchemaV1.Issue[]} issues
73+
*/
74+
constructor(issues) {
75+
super('Validation failed');
76+
this.name = 'ValidationError';
77+
this.issues = issues;
78+
}
79+
}
80+
6581
export { init_remote_functions } from './remote-functions.js';

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,10 +1992,13 @@ type ExtractId<Input> = Input extends { id: infer Id }
19921992
: string | number;
19931993

19941994
/**
1995-
* Recursively maps an input type to a structure where each field can create a validation issue.
1996-
* This mirrors the runtime behavior of the `invalid` proxy passed to form handlers.
1995+
* A function and proxy object used to imperatively create validation errors in form handlers.
1996+
*
1997+
* Access properties to create field-specific issues: `issue.fieldName('message')`.
1998+
* The type structure mirrors the input data structure for type-safe field access.
1999+
* Call `invalid(issue.foo(...), issue.nested.bar(...))` to throw a validation error.
19972000
*/
1998-
type InvalidField<T> =
2001+
export type InvalidField<T> =
19992002
WillRecurseIndefinitely<T> extends true
20002003
? Record<string | number, any>
20012004
: NonNullable<T> extends string | number | boolean | File
@@ -2011,15 +2014,12 @@ type InvalidField<T> =
20112014
: Record<string, never>;
20122015

20132016
/**
2014-
* A function and proxy object used to imperatively create validation errors in form handlers.
2015-
*
2016-
* Call `invalid(issue1, issue2, ...issueN)` to throw a validation error.
2017-
* If an issue is a `string`, it applies to the form as a whole (and will show up in `fields.allIssues()`)
2018-
* Access properties to create field-specific issues: `invalid.fieldName('message')`.
2019-
* The type structure mirrors the input data structure for type-safe field access.
2017+
* A validation error thrown by `invalid`.
20202018
*/
2021-
export type Invalid<Input = any> = ((...issues: Array<string | StandardSchemaV1.Issue>) => never) &
2022-
InvalidField<Input>;
2019+
export interface ValidationError {
2020+
/** The validation issues */
2021+
issues: StandardSchemaV1.Issue[];
2022+
}
20232023

20242024
/**
20252025
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.

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

Lines changed: 60 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { RemoteFormInput, RemoteForm } from '@sveltejs/kit' */
1+
/** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */
22
/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */
33
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
44
import { get_request_store } from '@sveltejs/kit/internal/server';
@@ -13,6 +13,7 @@ import {
1313
flatten_issues
1414
} from '../../../form-utils.js';
1515
import { get_cache, run_remote_function } from './shared.js';
16+
import { ValidationError } from '@sveltejs/kit/internal';
1617

1718
/**
1819
* Creates a form object that can be spread onto a `<form>` element.
@@ -21,7 +22,7 @@ import { get_cache, run_remote_function } from './shared.js';
2122
*
2223
* @template Output
2324
* @overload
24-
* @param {(invalid: import('@sveltejs/kit').Invalid<void>) => MaybePromise<Output>} fn
25+
* @param {() => MaybePromise<Output>} fn
2526
* @returns {RemoteForm<void, Output>}
2627
* @since 2.27
2728
*/
@@ -34,7 +35,7 @@ import { get_cache, run_remote_function } from './shared.js';
3435
* @template Output
3536
* @overload
3637
* @param {'unchecked'} validate
37-
* @param {(data: Input, invalid: import('@sveltejs/kit').Invalid<Input>) => MaybePromise<Output>} fn
38+
* @param {(data: Input, issue: InvalidField<Input>) => MaybePromise<Output>} fn
3839
* @returns {RemoteForm<Input, Output>}
3940
* @since 2.27
4041
*/
@@ -47,15 +48,15 @@ import { get_cache, run_remote_function } from './shared.js';
4748
* @template Output
4849
* @overload
4950
* @param {Schema} validate
50-
* @param {(data: StandardSchemaV1.InferOutput<Schema>, invalid: import('@sveltejs/kit').Invalid<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
51+
* @param {(data: StandardSchemaV1.InferOutput<Schema>, issue: InvalidField<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
5152
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
5253
* @since 2.27
5354
*/
5455
/**
5556
* @template {RemoteFormInput} Input
5657
* @template Output
5758
* @param {any} validate_or_fn
58-
* @param {(data_or_invalid: any, invalid?: any) => MaybePromise<Output>} [maybe_fn]
59+
* @param {(data_or_issue: any, issue?: any) => MaybePromise<Output>} [maybe_fn]
5960
* @returns {RemoteForm<Input, Output>}
6061
* @since 2.27
6162
*/
@@ -165,7 +166,7 @@ export function form(validate_or_fn, maybe_fn) {
165166

166167
state.refreshes ??= {};
167168

168-
const invalid = create_invalid();
169+
const issue = create_issues();
169170

170171
try {
171172
output.result = await run_remote_function(
@@ -174,7 +175,7 @@ export function form(validate_or_fn, maybe_fn) {
174175
true,
175176
data,
176177
(d) => d,
177-
(data) => (!maybe_fn ? fn(invalid) : fn(data, invalid))
178+
(data) => (!maybe_fn ? fn() : fn(data, issue))
178179
);
179180
} catch (e) {
180181
if (e instanceof ValidationError) {
@@ -328,89 +329,72 @@ function handle_issues(output, issues, is_remote_request, form_data) {
328329

329330
/**
330331
* Creates an invalid function that can be used to imperatively mark form fields as invalid
331-
* @returns {import('@sveltejs/kit').Invalid}
332+
* @returns {InvalidField<any>}
332333
*/
333-
function create_invalid() {
334-
/**
335-
* @param {...(string | StandardSchemaV1.Issue)} issues
336-
* @returns {never}
337-
*/
338-
function invalid(...issues) {
339-
throw new ValidationError(
340-
issues.map((issue) => {
341-
if (typeof issue === 'string') {
342-
return {
343-
path: [],
344-
message: issue
345-
};
334+
function create_issues() {
335+
return /** @type {InvalidField<any>} */ (
336+
new Proxy(
337+
/** @param {string} message */
338+
(message) => {
339+
// TODO 3.0 remove
340+
if (typeof message !== 'string') {
341+
throw new Error(
342+
'`invalid` should now be imported from `@sveltejs/kit` to throw validation issues. ' +
343+
"The second parameter provided to the form function (renamed to `issue`) is still used to construct issues, e.g. `invalid(issue.field('message'))`. " +
344+
'For more info see https://github.com/sveltejs/kit/pulls/14768'
345+
);
346346
}
347347

348-
return issue;
349-
})
350-
);
351-
}
352-
353-
return /** @type {import('@sveltejs/kit').Invalid} */ (
354-
new Proxy(invalid, {
355-
get(target, prop) {
356-
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
348+
return create_issue(message);
349+
},
350+
{
351+
get(target, prop) {
352+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
357353

358-
/**
359-
* @param {string} message
360-
* @param {(string | number)[]} path
361-
* @returns {StandardSchemaV1.Issue}
362-
*/
363-
const create_issue = (message, path = []) => ({
364-
message,
365-
path
366-
});
367-
368-
return create_issue_proxy(prop, create_issue, []);
354+
return create_issue_proxy(prop, []);
355+
}
369356
}
370-
})
357+
)
371358
);
372-
}
373359

374-
/**
375-
* Error thrown when form validation fails imperatively
376-
*/
377-
class ValidationError extends Error {
378360
/**
379-
* @param {StandardSchemaV1.Issue[]} issues
361+
* @param {string} message
362+
* @param {(string | number)[]} path
363+
* @returns {StandardSchemaV1.Issue}
380364
*/
381-
constructor(issues) {
382-
super('Validation failed');
383-
this.name = 'ValidationError';
384-
this.issues = issues;
365+
function create_issue(message, path = []) {
366+
return {
367+
message,
368+
path
369+
};
385370
}
386-
}
387-
388-
/**
389-
* Creates a proxy that builds up a path and returns a function to create an issue
390-
* @param {string | number} key
391-
* @param {(message: string, path: (string | number)[]) => StandardSchemaV1.Issue} create_issue
392-
* @param {(string | number)[]} path
393-
*/
394-
function create_issue_proxy(key, create_issue, path) {
395-
const new_path = [...path, key];
396371

397372
/**
398-
* @param {string} message
399-
* @returns {StandardSchemaV1.Issue}
373+
* Creates a proxy that builds up a path and returns a function to create an issue
374+
* @param {string | number} key
375+
* @param {(string | number)[]} path
400376
*/
401-
const issue_func = (message) => create_issue(message, new_path);
377+
function create_issue_proxy(key, path) {
378+
const new_path = [...path, key];
402379

403-
return new Proxy(issue_func, {
404-
get(target, prop) {
405-
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
380+
/**
381+
* @param {string} message
382+
* @returns {StandardSchemaV1.Issue}
383+
*/
384+
const issue_func = (message) => create_issue(message, new_path);
406385

407-
// Handle array access like invalid.items[0]
408-
if (/^\d+$/.test(prop)) {
409-
return create_issue_proxy(parseInt(prop, 10), create_issue, new_path);
410-
}
386+
return new Proxy(issue_func, {
387+
get(target, prop) {
388+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
411389

412-
// Handle property access like invalid.field.nested
413-
return create_issue_proxy(prop, create_issue, new_path);
414-
}
415-
});
390+
// Handle array access like invalid.items[0]
391+
if (/^\d+$/.test(prop)) {
392+
return create_issue_proxy(parseInt(prop, 10), new_path);
393+
}
394+
395+
// Handle property access like invalid.field.nested
396+
return create_issue_proxy(prop, new_path);
397+
}
398+
});
399+
}
416400
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { form } from '$app/server';
2-
import { error } from '@sveltejs/kit';
2+
import { error, invalid } from '@sveltejs/kit';
33
import * as v from 'valibot';
44

55
export const my_form = form(
@@ -8,10 +8,10 @@ export const my_form = form(
88
bar: v.picklist(['d', 'e', 'f']),
99
button: v.optional(v.literal('submitter'))
1010
}),
11-
async (data, invalid) => {
11+
async (data, issue) => {
1212
// Test imperative validation
1313
if (data.foo === 'c') {
14-
invalid(invalid.foo('Imperative: foo cannot be c'));
14+
invalid(issue.foo('Imperative: foo cannot be c'));
1515
}
1616

1717
console.log(data);

0 commit comments

Comments
 (0)