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 bcbb3e9c1570..e70e4f33cc13 100644
--- a/documentation/docs/20-core-concepts/60-remote-functions.md
+++ b/documentation/docs/20-core-concepts/60-remote-functions.md
@@ -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';
@@ -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`)
);
}
}
diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js
index 8663772f0c0c..8923ea96a3a6 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,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
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 5bc86276b4f5..d0f3663fe171 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -1992,10 +1992,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
@@ -2011,15 +2014,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 c1fe5a1d2c10..b23517823c15 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.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 `