Shared Schema (Client + Server)
Step 1Define 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.
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 AThe 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().
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};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 BFor 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.
409 Conflict on the username field. Second submit succeeds.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};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.setErrorscontroller.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.
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" />