diff --git a/.changeset/icy-glasses-agree.md b/.changeset/icy-glasses-agree.md
new file mode 100644
index 000000000000..2f103d4a8d9d
--- /dev/null
+++ b/.changeset/icy-glasses-agree.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+breaking: `invalid` now must be imported from `@sveltejs/kit`
diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md
index 969b90d17121..bf1bbcf69aab 100644
--- a/documentation/docs/20-core-concepts/60-remote-functions.md
+++ b/documentation/docs/20-core-concepts/60-remote-functions.md
@@ -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';
@@ -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`)
);
}
}
diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js
index 8663772f0c0c..0d78e017cebd 100644
--- a/packages/kit/src/exports/index.js
+++ b/packages/kit/src/exports/index.js
@@ -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,
@@ -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
diff --git a/packages/kit/src/exports/internal/index.js b/packages/kit/src/exports/internal/index.js
index b87448b30914..8ef0a32a32c8 100644
--- a/packages/kit/src/exports/internal/index.js
+++ b/packages/kit/src/exports/internal/index.js
@@ -1,3 +1,5 @@
+/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
+
export class HttpError {
/**
* @param {number} status
@@ -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';
diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts
index cb42dc6cc1c0..ef6ccef0c9d1 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -1974,10 +1974,13 @@ type ExtractId = 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 =
+export type InvalidField =
WillRecurseIndefinitely extends true
? Record
: NonNullable extends string | number | boolean | File
@@ -1993,15 +1996,12 @@ type InvalidField =
: Record;
/**
- * 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 = ((...issues: Array) => never) &
- InvalidField;
+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.
diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js
index 9d5b5b551a4d..e90a7ce7668b 100644
--- a/packages/kit/src/runtime/app/server/remote/form.js
+++ b/packages/kit/src/runtime/app/server/remote/form.js
@@ -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';
@@ -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 `