Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/icy-glasses-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

breaking: `invalid` now must be imported from `@sveltejs/kit`
11 changes: 8 additions & 3 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,12 @@ Alternatively, you could use `select` and `select multiple`:

### Programmatic validation

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:
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 standard-schema-compliant issues. Use the `issue` parameter for type-safe creation of such issues:

```js
/// file: src/routes/shop/data.remote.js
import * as v from 'valibot';
import { invalid } from '@sveltejs/kit';
import { form } from '$app/server';
import * as db from '$lib/server/database';

Expand All @@ -467,13 +468,17 @@ export const buyHotcakes = form(
v.minValue(1, 'you must buy at least one hotcake')
)
}),
async (data, invalid) => {
async (data, issue) => {
try {
await db.buy(data.qty);
} catch (e) {
if (e.code === 'OUT_OF_STOCK') {
invalid(
invalid.qty(`we don't have enough hotcakes`)
// This will show up on the root issue list
'Purchase failed',
// Creates a `{ message: ..., path: ['qty'] }` object,
// will show up on the issue list for the `qty` field
issue.qty(`we don't have enough hotcakes`)
);
}
}
Expand Down
50 changes: 49 additions & 1 deletion packages/kit/src/exports/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { HttpError, Redirect, ActionFailure } from './internal/index.js';
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */

import { HttpError, Redirect, ActionFailure, ValidationError } from './internal/index.js';
import { BROWSER, DEV } from 'esm-env';
import {
add_data_suffix,
Expand Down Expand Up @@ -215,6 +217,52 @@ export function isActionFailure(e) {
return e instanceof ActionFailure;
}

/**
* Use this to throw a validation error to imperatively fail form validation.
* Can be used in combination with `issue` passed to form actions to create field-specific issues.
*
* @example
* ```ts
* import { invalid } from '@sveltejs/kit';
* import { form } from '$app/server';
* import * as v from 'valibot';
*
* function tryRegisterUser(name: string, password: string) {
* // ...
* }
*
* export const register = form(
* v.object({ name: v.string(), _password: v.string() }),
* async ({ name, _password }, issue) => {
* const success = tryRegisterUser(name, _password);
* if (!success) {
* invalid('Registration failed', issue.name('This username is already taken'));
* }
*
* // ...
* }
* );
* ```
* @param {...(StandardSchemaV1.Issue | string)} issues
* @returns {never}
* @since 2.47.3
*/
export function invalid(...issues) {
throw new ValidationError(
issues.map((issue) => (typeof issue === 'string' ? { message: issue } : issue))
);
}

/**
* Checks whether this is an validation error thrown by {@link invalid}.
* @param {unknown} e The object to check.
* @return {e is import('./public.js').ActionFailure}
* @since 2.47.3
*/
export function isValidationError(e) {
return e instanceof ValidationError;
}

/**
* Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname.
* Returns the normalized URL as well as a method for adding the potential suffix back
Expand Down
16 changes: 16 additions & 0 deletions packages/kit/src/exports/internal/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */

export class HttpError {
/**
* @param {number} status
Expand Down Expand Up @@ -62,4 +64,18 @@ export class ActionFailure {
}
}

/**
* Error thrown when form validation fails imperatively
*/
export class ValidationError extends Error {
/**
* @param {StandardSchemaV1.Issue[]} issues
*/
constructor(issues) {
super('Validation failed');
this.name = 'ValidationError';
this.issues = issues;
}
}

export { init_remote_functions } from './remote-functions.js';
22 changes: 11 additions & 11 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1974,10 +1974,13 @@ type ExtractId<Input> = Input extends { id: infer Id }
: string | number;

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

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

/**
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
Expand Down
136 changes: 60 additions & 76 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @import { RemoteFormInput, RemoteForm } from '@sveltejs/kit' */
/** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */
/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
import { get_request_store } from '@sveltejs/kit/internal/server';
Expand All @@ -13,6 +13,7 @@ import {
flatten_issues
} from '../../../form-utils.svelte.js';
import { get_cache, run_remote_function } from './shared.js';
import { ValidationError } from '@sveltejs/kit/internal';

/**
* Creates a form object that can be spread onto a `<form>` element.
Expand All @@ -21,7 +22,7 @@ import { get_cache, run_remote_function } from './shared.js';
*
* @template Output
* @overload
* @param {(invalid: import('@sveltejs/kit').Invalid<void>) => MaybePromise<Output>} fn
* @param {() => MaybePromise<Output>} fn
* @returns {RemoteForm<void, Output>}
* @since 2.27
*/
Expand All @@ -34,7 +35,7 @@ import { get_cache, run_remote_function } from './shared.js';
* @template Output
* @overload
* @param {'unchecked'} validate
* @param {(data: Input, invalid: import('@sveltejs/kit').Invalid<Input>) => MaybePromise<Output>} fn
* @param {(data: Input, issue: InvalidField<Input>) => MaybePromise<Output>} fn
* @returns {RemoteForm<Input, Output>}
* @since 2.27
*/
Expand All @@ -47,15 +48,15 @@ import { get_cache, run_remote_function } from './shared.js';
* @template Output
* @overload
* @param {Schema} validate
* @param {(data: StandardSchemaV1.InferOutput<Schema>, invalid: import('@sveltejs/kit').Invalid<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
* @param {(data: StandardSchemaV1.InferOutput<Schema>, issue: InvalidField<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
* @since 2.27
*/
/**
* @template {RemoteFormInput} Input
* @template Output
* @param {any} validate_or_fn
* @param {(data_or_invalid: any, invalid?: any) => MaybePromise<Output>} [maybe_fn]
* @param {(data_or_issue: any, issue?: any) => MaybePromise<Output>} [maybe_fn]
* @returns {RemoteForm<Input, Output>}
* @since 2.27
*/
Expand Down Expand Up @@ -165,7 +166,7 @@ export function form(validate_or_fn, maybe_fn) {

state.refreshes ??= {};

const invalid = create_invalid();
const issue = create_issues();

try {
output.result = await run_remote_function(
Expand All @@ -174,7 +175,7 @@ export function form(validate_or_fn, maybe_fn) {
true,
data,
(d) => d,
(data) => (!maybe_fn ? fn(invalid) : fn(data, invalid))
(data) => (!maybe_fn ? fn() : fn(data, issue))
);
} catch (e) {
if (e instanceof ValidationError) {
Expand Down Expand Up @@ -329,89 +330,72 @@ function handle_issues(output, issues, is_remote_request, form_data) {

/**
* Creates an invalid function that can be used to imperatively mark form fields as invalid
* @returns {import('@sveltejs/kit').Invalid}
* @returns {InvalidField<any>}
*/
function create_invalid() {
/**
* @param {...(string | StandardSchemaV1.Issue)} issues
* @returns {never}
*/
function invalid(...issues) {
throw new ValidationError(
issues.map((issue) => {
if (typeof issue === 'string') {
return {
path: [],
message: issue
};
function create_issues() {
return /** @type {InvalidField<any>} */ (
new Proxy(
/** @param {string} message */
(message) => {
// TODO 3.0 remove
if (typeof message !== 'string') {
throw new Error(
'invalid() should now be imported from @sveltejs/kit to throw validaition issues. ' +
'Keep using the parameter (now named issue) provided to the form function only to construct the issues, e.g. invalid(issue.field("message")). ' +
'For more info see https://github.com/sveltejs/kit/pulls/14768'
);
}

return issue;
})
);
}

return /** @type {import('@sveltejs/kit').Invalid} */ (
new Proxy(invalid, {
get(target, prop) {
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
return create_issue(message);
},
{
get(target, prop) {
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];

/**
* @param {string} message
* @param {(string | number)[]} path
* @returns {StandardSchemaV1.Issue}
*/
const create_issue = (message, path = []) => ({
message,
path
});

return create_issue_proxy(prop, create_issue, []);
return create_issue_proxy(prop, []);
}
}
})
)
);
}

/**
* Error thrown when form validation fails imperatively
*/
class ValidationError extends Error {
/**
* @param {StandardSchemaV1.Issue[]} issues
* @param {string} message
* @param {(string | number)[]} path
* @returns {StandardSchemaV1.Issue}
*/
constructor(issues) {
super('Validation failed');
this.name = 'ValidationError';
this.issues = issues;
function create_issue(message, path = []) {
return {
message,
path
};
}
}

/**
* Creates a proxy that builds up a path and returns a function to create an issue
* @param {string | number} key
* @param {(message: string, path: (string | number)[]) => StandardSchemaV1.Issue} create_issue
* @param {(string | number)[]} path
*/
function create_issue_proxy(key, create_issue, path) {
const new_path = [...path, key];

/**
* @param {string} message
* @returns {StandardSchemaV1.Issue}
* Creates a proxy that builds up a path and returns a function to create an issue
* @param {string | number} key
* @param {(string | number)[]} path
*/
const issue_func = (message) => create_issue(message, new_path);
function create_issue_proxy(key, path) {
const new_path = [...path, key];

return new Proxy(issue_func, {
get(target, prop) {
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
/**
* @param {string} message
* @returns {StandardSchemaV1.Issue}
*/
const issue_func = (message) => create_issue(message, new_path);

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

// Handle property access like invalid.field.nested
return create_issue_proxy(prop, create_issue, new_path);
}
});
// Handle array access like invalid.items[0]
if (/^\d+$/.test(prop)) {
return create_issue_proxy(parseInt(prop, 10), new_path);
}

// Handle property access like invalid.field.nested
return create_issue_proxy(prop, new_path);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { form } from '$app/server';
import { invalid } from '@sveltejs/kit';
import * as v from 'valibot';

export const my_form = form(
Expand All @@ -7,10 +8,10 @@ export const my_form = form(
bar: v.picklist(['d', 'e', 'f']),
button: v.optional(v.literal('submitter'))
}),
async (data, invalid) => {
async (data, issue) => {
// Test imperative validation
if (data.foo === 'c') {
invalid(invalid.foo('Imperative: foo cannot be c'));
invalid(issue.foo('Imperative: foo cannot be c'));
}

console.log(data);
Expand Down
Loading
Loading