Shared Schema (Client + Server)

Step 1

Define the schema once in a shared file (e.g. $lib/schemas/contact.ts) and import it on both the client component and the server action. Effect Schema runs identically in both environments — no duplication, no drift between client and server validation rules.

$lib/schemas/contact.ts TypeScript
1
2// src/lib/schemas/contact.ts
3// Define the schema once — import it on both client and server.
4import { Schema, pipe } from "effect";
5import { withField, withFormLayout } from "@kareyes/aether/forms";
6
7export const ContactSchema = pipe(
8	Schema.Struct({
9		name: pipe(
10			Schema.String,
11			Schema.minLength(2),
12			Schema.annotations({ message: () => "Name must be at least 2 characters" }),
13			withField({ label: "Full Name", placeholder: "Jane Doe" })
14		),
15		email: pipe(
16			Schema.String,
17			Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
18			Schema.annotations({ message: () => "Enter a valid email address" }),
19			withField({ label: "Email", inputType: "email" })
20		),
21		subject: pipe(
22			Schema.String,
23			Schema.minLength(5),
24			Schema.annotations({ message: () => "Subject must be at least 5 characters" }),
25			withField({ label: "Subject", placeholder: "How can we help?" })
26		),
27		message: pipe(
28			Schema.String,
29			Schema.minLength(20),
30			Schema.annotations({ message: () => "Message must be at least 20 characters" }),
31			withField({ label: "Message", inputType: "textarea", placeholder: "Describe your issue..." })
32		)
33	}),
34	withFormLayout({ columns: 1, sections: [{ id: "main", title: "Contact Us" }] })
35);
36
37// TypeScript type automatically inferred from schema
38export type ContactData = typeof ContactSchema.Type;

SvelteKit Form Actions

Pattern A

The classic SvelteKit server pattern. The action in +page.server.ts receives native FormData, validates it with the shared schema using Schema.decodeUnknownEither, and returns fail(422, { errors } ) on failure. The client uses use:enhance for progressive enhancement and maps form?.errors back to the controller via controller.setErrors().

Demo: First submit returns a simulated server error on the email field. Second submit succeeds.

+page.server.ts TypeScript
1
2// src/routes/contact/+page.server.ts
3import { fail, redirect } from "@sveltejs/kit";
4import { Schema, Either, pipe } from "effect";
5import type { Actions } from "./$types";
6import { ContactSchema } from "$lib/schemas/contact";
7
8// Parse Effect ParseError into a flat { field: message } map
9function parseSchemaErrors(error: Schema.ParseError): Record<string, string> {
10	const errors: Record<string, string> = {};
11	for (const issue of error.issues) {
12		const field = issue.path?.[0]?.toString();
13		if (field) errors[field] = issue.message ?? "Invalid value";
14	}
15	return errors;
16}
17
18export const actions: Actions = {
19	default: async ({ request }) => {
20		const formData = await request.formData();
21		const raw = Object.fromEntries(formData);
22
23		// Server-side validation using the same shared schema
24		const result = Schema.decodeUnknownEither(ContactSchema)(raw);
25
26		if (Either.isLeft(result)) {
27			// Return 422 with field errors and original values for re-rendering
28			return fail(422, {
29				errors: parseSchemaErrors(result.left),
30				values: raw
31			});
32		}
33
34		// ✅ Data is validated and typed as ContactData
35		const data = result.right;
36		await sendContactEmail(data);
37
38		throw redirect(303, "/contact/success");
39	}
40};
+page.svelte (client) Svelte
1
2<!-- src/routes/contact/+page.svelte -->
3<script lang="ts">
4	import { enhance } from "$app/forms";
5	import { SchemaForm, FormController } from "@kareyes/aether/forms";
6	import { ContactSchema } from "$lib/schemas/contact";
7	import type { ActionData } from "./$types";
8
9	let { form }: { form: ActionData } = $props();
10
11	const controller = new FormController(ContactSchema, { validateOnBlur: true });
12
13	// Map server-returned errors back to the form on navigation
14	$effect(() => {
15		if (form?.errors) {
16			// controller.setErrors() places server errors on the matching fields
17			controller.setErrors(form.errors);
18		}
19		// Re-populate field values from the failed submission
20		if (form?.values) {
21			controller.setValues(form.values);
22		}
23	});
24</script>
25
26<!--
27	use:enhance upgrades the native <form> with JS:
28	- Prevents full page reload
29	- Streams action result back
30	- Updates $form reactive store
31	The form still works without JS (progressive enhancement).
32-->
33<form method="POST" use:enhance>
34	<SchemaForm
35		{controller}
36		submitText="Send Message"
37	/>
38</form>

API Endpoint + fetch Submission

Pattern B

For JSON APIs or SPAs, create a +server.ts endpoint that validates the request body and returns JSON errors. The client's onSubmit handler calls fetch, checks the response, and maps errors onto the form using controller.setErrors(result.errors). No page reload required.

Demo: First submit returns a simulated 409 Conflict on the username field. Second submit succeeds.

Min 8 characters


+server.ts (API endpoint) TypeScript
1
2// src/routes/api/register/+server.ts
3import { json } from "@sveltejs/kit";
4import { Schema, Either, pipe } from "effect";
5import type { RequestHandler } from "./$types";
6
7const RegistrationSchema = pipe(
8	Schema.Struct({
9		username: pipe(Schema.String, Schema.minLength(3), Schema.maxLength(20)),
10		email: Schema.String,
11		password: pipe(Schema.String, Schema.minLength(8))
12	})
13);
14
15function parseSchemaErrors(error: Schema.ParseError): Record<string, string> {
16	const errors: Record<string, string> = {};
17	for (const issue of error.issues) {
18		const field = issue.path?.[0]?.toString();
19		if (field) errors[field] = issue.message ?? "Invalid value";
20	}
21	return errors;
22}
23
24export const POST: RequestHandler = async ({ request }) => {
25	// 1. Parse JSON body
26	const body = await request.json();
27
28	// 2. Validate with schema
29	const result = Schema.decodeUnknownEither(RegistrationSchema)(body);
30
31	if (Either.isLeft(result)) {
32		return json({ errors: parseSchemaErrors(result.left) }, { status: 422 });
33	}
34
35	const { username, email, password } = result.right;
36
37	// 3. Business-logic checks (uniqueness, etc.)
38	const existingUser = await db.users.findByUsername(username);
39	if (existingUser) {
40		return json({ errors: { username: "Username is already taken" } }, { status: 409 });
41	}
42
43	// 4. Save and return
44	const user = await db.users.create({ username, email, password });
45	return json({ success: true, id: user.id }, { status: 201 });
46};
+page.svelte (fetch client) Svelte
1
2<script lang="ts">
3	import { Schema, pipe } from "effect";
4	import { SchemaForm, FormController, withField, withFormLayout } from "@kareyes/aether/forms";
5
6	const RegistrationSchema = pipe(
7		Schema.Struct({
8			username: pipe(
9				Schema.String, Schema.minLength(3),
10				withField({ label: "Username" })
11			),
12			email: pipe(
13				Schema.String,
14				Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
15				withField({ label: "Email", inputType: "email" })
16			),
17			password: pipe(
18				Schema.String, Schema.minLength(8),
19				withField({ label: "Password", inputType: "password" })
20			)
21		}),
22		withFormLayout({ columns: 1, sections: [{ id: "main", title: "Create Account" }] })
23	);
24
25	const controller = new FormController(RegistrationSchema, { validateOnBlur: true });
26
27	async function handleSubmit(data: typeof RegistrationSchema.Type) {
28		const res = await fetch("/api/register", {
29			method: "POST",
30			headers: { "Content-Type": "application/json" },
31			body: JSON.stringify(data)
32		});
33
34		const result = await res.json();
35
36		if (!res.ok && result.errors) {
37			// Map server errors (e.g. "username taken") directly onto form fields
38			controller.setErrors(result.errors);
39			return;
40		}
41
42		// ✅ Success — redirect or show confirmation
43		console.log("Registered:", result.id);
44	}
45</script>
46
47<SchemaForm {controller} onSubmit={handleSubmit} submitText="Create Account" />

Server Error Mapping

controller.setErrors

controller.setErrors({ field: 'message' }) accepts any {field: message} object and places each error inline below its field — exactly like client-side validation. controller.setFieldError("field", "msg") sets a single field. Use this to surface uniqueness conflicts, rate-limit errors, or any business-logic failure from the server.

Simulate server response:

Server error mapping patterns Svelte
1
2<script lang="ts">
3	import { SchemaForm, FormController, withField, withFormLayout } from "@kareyes/aether/forms";
4	import { Schema, pipe } from "effect";
5
6	const schema = pipe(
7		Schema.Struct({
8			username: pipe(Schema.String, withField({ label: "Username" })),
9			email: pipe(Schema.String, withField({ label: "Email", inputType: "email" }))
10		}),
11		withFormLayout({ columns: 1, sections: [{ id: "main", title: "Update Profile" }] })
12	);
13
14	const controller = new FormController(schema);
15
16	async function handleSubmit(data) {
17		const res = await fetch("/api/profile", {
18			method: "PATCH",
19			headers: { "Content-Type": "application/json" },
20			body: JSON.stringify(data)
21		});
22
23		const result = await res.json();
24
25		if (res.status === 409) {
26			// Conflict — e.g. duplicate email or username
27			controller.setErrors(result.errors);
28			// { email: "This email is already registered" }
29			return;
30		}
31
32		if (res.status === 422) {
33			// Server-side validation failure
34			controller.setErrors(result.errors);
35			return;
36		}
37
38		if (!res.ok) {
39			// Generic server error — set on a specific field or show a toast
40			controller.setFieldError("username", "Server error. Please try again.");
41			return;
42		}
43
44		// ✅ Success
45		console.log("Profile updated:", result);
46	}
47</script>
48
49<!--
50	controller.setErrors({ field: "message" }) — map a { field: message } object to the form
51	controller.setFieldError("field", "message") — set a single field error
52	Both work the same as client-side validation errors — shown inline below the field.
53-->
54<SchemaForm {controller} onSubmit={handleSubmit} submitText="Save Profile" />