Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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`
7 changes: 4 additions & 3 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,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 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:

```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 @@ -469,13 +470,13 @@ 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`)
issue.qty(`we don't have enough hotcakes`)
);
}
}
Expand Down
47 changes: 46 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,49 @@ 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 { tryLogin } from '$lib/server/auth';
* import * as v from 'valibot';
*
* export const login = form(
* v.object({ name: v.string(), _password: v.string() }),
* async ({ name, _password }) => {
* const success = tryLogin(name, _password);
* if (!success) {
* invalid('Incorrect username or password');
* }
*
* // ...
* }
* );
* ```
* @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 @@ -1992,10 +1992,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 @@ -2011,15 +2014,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.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 @@ -328,89 +329,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 validation issues. ' +
"The second parameter provided to the form function (renamed to `issue`) is still used to construct 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,5 +1,5 @@
import { form } from '$app/server';
import { error } from '@sveltejs/kit';
import { error, invalid } from '@sveltejs/kit';
import * as v from 'valibot';

export const my_form = form(
Expand All @@ -8,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