SvelteKit SchemaForm

Published: August 24, 2023 Updated: October 12, 2023

SvelteKit SchemaForm

Table of Contents

Setup

Install Dependencies

You will need to have a schema validation library installed. This component works with almost every validation library, including Zod, Valibot, etc. For this example, I am using Valibot.

In addition, you will need to install the decode-formdata library to parse the FormData on the server side and @decs/typeschema to handle the schema validation. You can install these with the following command:

pnpm add decode-formdata @decs/typeschema

The Types

The following types are used by the component. They are all self-contained in a single file, so you can copy and paste them into your project.

// @/types/utils.ts

/**
 * Converts all types in an object type to strings.
 * 
 * @author Matt DeKok
 */
export type DeepStringify<T> = {
	[K in keyof T]: T[K] extends Array<infer E>
		? DeepStringify<Array<E>>
		: T[K] extends Date | Blob | File
		? string
		: T[K] extends object
		? DeepStringify<T[K]>
		: string;
};

// Helper types for Paths
type Join<K, P> = K extends string | number ? (P extends string | number ? `${K}${"" extends P ? "" : "."}${P}` : never) : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]];

/**
 * Gets all possible paths of an object type.
 * 
 * @source https://stackoverflow.com/questions/67607069/typescript-set-value-type-of-a-deep-nested-object-path
 */
export type Paths<T, D extends number = 10> = [D] extends [never]
	? never
	: T extends object
	? {
			[K in keyof T]-?: K extends string | number
				? DeepStringify<T>[K] extends string
					? `${K}` | Join<K, Paths<T[K], Prev[D]>>
					: Join<K, Paths<T[K], Prev[D]>>
				: never;
	  }[keyof T]
	: "";

/**
 * `SvelteMap` class, a subclass of JavaScript's `Map` class with modified methods for 
 * remove and clear operations that return the `Map` itself for chain invocations.
 * 
 * @author Matt DeKok
 */
export class SvelteMap<TKey, TVal> extends Map<TKey, TVal> {
	constructor(iterable?: Iterable<readonly [TKey, TVal]> | null) {
		super(iterable);
	}

	/**
	 * `remove` method for `SvelteMap`, deletes an element with the specified key from the Map.
	 * @param key - Key of the element to remove from the Map.
	 * @returns `this` - Returns the `Map` itself after removal for chain invocations.
	 */
	public remove(key: TKey) {
		super.delete(key);
		return this;
	}

	/**
	 * `clear` method for `SvelteMap`, removes all elements from the Map.
	 * @returns `this` - Returns the `Map` itself after the operation for chain invocations.
	 */
	public clear() {
		super.clear();
		return this;
	}
}

The Component

The following is a self-contained SvelteKit component which extends SvelteKit forms with schema validation. With the use of @decs/typeschema as the only dependency, it accepts almost every schema validation library, including zod, valibot, etc.

<!-- @/components/SchemaForm.svelte -->
<script lang="ts" context="module">
	import type { Infer, InferIn, Schema } from "@decs/typeschema";
	import { validate } from "@decs/typeschema";
	import { decode } from "decode-formdata";

	/**
	* Parse form data from strings to their correct types and
	* validate them against a schema from Zod, Yup, Valibot, etc.
	*
	* @param formData The form data to parse
	* @param schema The schema to validate against
	* @param info Arrays of field names that should be parsed as arrays, booleans, dates, files or numbers
	* @param info.arrays Field names that should be parsed as arrays
	* @param info.booleans Field names that should be parsed as booleans. "false", "0" or undefined will 
	* be converted to false. Everything else will be converted to true.
	* @param info.dates Field names that should be parsed as dates. Empty dates will be converted to null.
	* @param info.files Field names that should be parsed as files
	* @param info.numbers Field names that should be parsed as numbers
	*
	* @returns The parsed and validated data
	*/
	export async function parseFormData<TSchema extends Schema>(
		formData: FormData,
		schema: TSchema,
		info?: Partial<{
			/**
			* Field names that should be parsed as arrays
			*/
			arrays: string[];
			/**
			* Field names that should be parsed as booleans. "false", "0" or undefined will 
			* be converted to false. Everything else will be converted to true.
			*/
			booleans: string[];
			/**
			* Field names that should be parsed as dates. Empty dates will be converted to null.
			*/
			dates: string[];
			/**
			* Field names that should be parsed as files
			*/
			files: string[];
			/**
			* Field names that should be parsed as numbers
			*/
			numbers: string[];
		}>
	): Promise<Infer<TSchema>> {
		const formValues = decode(formData, info);
		const result = await validate(schema, formValues);
		if ("issues" in result && result.issues.length) {
			console.log("Value:", formValues);
			console.error("Issues:", result.issues);
			throw new Error([...new Set(result.issues.map((i) => i.message))].join(";\n"));
		}
		if (!("data" in result)) throw new Error("No data returned");
		return result.data;
	}
</script>

<script lang="ts" generics="TSchema extends Schema">
	import { enhance } from "$app/forms";
	import { beforeNavigate } from "$app/navigation";
	import type { ActionResult } from "@sveltejs/kit";
	import { createEventDispatcher } from "svelte";
	import { SvelteMap, type DeepStringify, type Paths } from "$/types/util";

	const dispatch = createEventDispatcher<{
		"before-submit": null;
		"after-submit": ActionResult;
		validate: {
			data?: Infer<TSchema>;
			changes: typeof changes;
			errors: typeof errors;
			setError: typeof setError;
		};
	}>();

	/**
	* A schema from Zod, Yup, Valibot, etc. to validate the form against
	*/
	export let schema: TSchema;
	/**
	* The data to be validated
	*/
	export let data: InferIn<TSchema>;
	/**
	* The URL to submit the form to
	*/
	export let action: string;
	/**
	* The HTTP method to use when submitting the form
	*/
	export let method = "POST";
	/**
	* Whether to reset the form after submitting
	*/
	export let resetOnSave = false;

	let initialStructure = emptyClone(data);
	$: currentStructure = emptyClone(data);

	let errors = new SvelteMap<"form" | Paths<typeof initialStructure, 6>, string>();
	let elForm: HTMLFormElement;
	let changes: Array<string> = [];
	let saving = false;

	async function checkChanges() {
		// Check for changes
		const formStructureIsDiff = JSON.stringify(currentStructure) !== JSON.stringify(initialStructure);
		changes = !saving
			? [...elForm.querySelectorAll("[data-dirty]")]
					.map((el) => el.getAttribute("name") || "hidden")
					.concat(formStructureIsDiff ? "form" : "")
					.filter(Boolean)
			: [];

		// Check for errors
		errors = new SvelteMap<"form" | Paths<typeof initialStructure, 6>, string>();
		const result = await validate(schema, data);
		if ("data" in result) {
			dispatch("validate", { data: result.data, changes, errors, setError });
		} else if ("issues" in result) {
			result.issues.forEach((issue) => {
				if (!issue.path) issue.path = ["form"];
				if (saving || changes.includes(issue.path.join("."))) {
					errors = errors.set(issue.path.join(".") as any, issue.message);
				}
			});
			dispatch("validate", { changes, errors, setError });
		}
	}

	$: {
		if (elForm && data) {
			checkChanges();
			setTimeout(() => {
				elForm.querySelectorAll(":is(input, textarea, select):not([data-listener])").forEach((el) => {
					el.setAttribute("data-listener", "");
					el.addEventListener("input", (ev: Event) => {
						if (ev.currentTarget instanceof Element && !ev.currentTarget.hasAttribute("data-dirty")) {
							ev.currentTarget.setAttribute("data-dirty", "");
							checkChanges();
						}
					});
				});
			}, 10);
		}
	}

	beforeNavigate((nav) => {
		checkChanges();
		if (changes.length) {
			if (!confirm("You have unsaved changes. Are you sure you want to leave this page?")) nav.cancel();
		}
	});

	function emptyClone<S extends Schema, T extends InferIn<S>>(data: T): DeepStringify<T> {
		const result: any = Array.isArray(data) ? [] : {};
		for (const key in data) {
			if (data[key] && ["Object", "Array"].includes((data[key] as object).constructor.name)) {
				result[key] = emptyClone(data[key]);
			} else result[key] = "";
		}
		return result;
	}

	function setError<K extends "form" | Paths<typeof initialStructure, 6>>(path: K, message: string) {
		errors = errors.set(path, message);
	}
</script>
<form
	{method}
	{action}
	{...$$restProps}
	bind:this={elForm}
	novalidate
	use:enhance={async (f) => {
		if (saving) return f.cancel();

		dispatch("before-submit");
		saving = true;

		// Check for errors before submitting
		await checkChanges();
		if (errors.size) {
			saving = false;
			return f.cancel();
		}

		// Change dates to ISO format in client to prevent timezone issues
		f.formData.forEach((value, key) => {
			const inputType = document.querySelector(`[name="${key}"]`)?.getAttribute("type");
			if (inputType?.includes("date") && typeof value === "string") {
				const d = new Date(value);
				if (!isNaN(d.getTime())) f.formData.set(key, d.toISOString());
			}
		});

		return async ({ update, result }) => {
			await update({ reset: resetOnSave });
			dispatch("after-submit", result);
			if (
				(result.type === "success" && result.data && "error" in result.data) ||
				!["redirect", "success"].includes(result.type)
			) {
				saving = false;
			}
		};
	}}
>
	<slot {errors} {saving} />
</form>

Examples

Basic Example

Using this component is very simple. In your +page.svelte, simply add it like you would another form and pass it an object schema and a data object. It also returns a saving boolean and errors map. Both data and errors are typesafe based on the the structure of your object schema.

// $libs/types/schemas
import { object, string, email, number, boolean, minValue } from "valibot";

export const subscribeSchema = object({
	name: string(),
	age: number([minValue(18, "Must be at least 18 years old")]),
	email: string([email("Must be a valid email")]),
	subscribe: boolean()
});
<!-- +page.svelte -->
<script lang="ts">
	import SchemaForm from "$lib/components/SchemaForm.svelte";
	import { subscribeSchema } from "$lib/types/schemas";

	let data = { name: "", age: 18, email: "", subscribe: true };
</script>
<SchemaForm action="?/subscribe" schema={subscribeSchema} data={data} let:saving let:errors resetOnSave>
	<div class="form-field">
		<label>Name</label>
		<input name="name" bind:value={data.name} required />
		{#if errors.has("name")}
			<span class="error">{errors.get("name")}</span>
		{/if}
	</div>
	<div class="form-field">
		<label>Age</label>
		<input name="age" type="number" min="18" size="3" bind:value={data.age} required />
		{#if errors.has("age")}
			<span class="error">{errors.get("age")}</span>
		{/if}
	</div>
	<div class="form-field">
		<label>Email</label>
		<input name="email" type="email" bind:value={data.email} required />
		{#if errors.has("email")}
			<span class="error">{errors.get("email")}</span>
		{/if}
	</div>
	<div class="form-field">
		<label>Subscribe to our newsletter</label>
		<input name="subscribe" type="checkbox" bind:checked={data.subscribe} />
	</div>
	<div>
		<button aria-disabled="{saving}">Subscribe</button>
	</div>
</SchemaForm>

Then in your server action, you can use the parseFormData method from the component to parse the form data on the server and get a validated data object.

As you can see, the third argument is an object with arrays of field names that should be parsed as arrays, booleans, dates, files or numbers. This is necessary because the FormData object only returns strings. For nested object data, the field names should be the data object path that corresponds to that field. See the Field Names section for more information.

// +page.server.ts
import { parseFormData } from "$lib/components/SchemaForm.svelte";
import { subscribeSchema } from "$lib/types/schemas";

export const actions = {
	subscribe: async (event) => {
		try {
			const data = await event.request.formData();
			const parsedData = await parseFormData(data, subscribeSchema, {
				numbers: ["age"],
				booleans: ["subscribe"]
			});
			...
		} catch (error) {
			...
		}
	}
};

Extending the Error Checking

The component also returns a validate event that you can use to extend the error checking. It returns the following:

data: The validated data object
changes: The names of the fields that have changed as an array of strings
errors: The errors map
setError: A function to set an error

The setError function is also type-safe. It takes two arguments: the path to the error as a string, and the value to set at that path. The path parameter will have autocomplete based on a dot-separated string that corresponds to the data object path. See the Field Names section for more information.

You can use this event to add custom error checking to your form. For example, if you have a password field and a confirm password field, you can use the validate event to check that they match.

<!-- +page.svelte -->
<script lang="ts">
	import SchemaForm from "$lib/components/SchemaForm.svelte";
	import { subscribeSchema } from "$lib/types/schemas";

	let data = { email: "", password: "", confirmPassword: "" };
</script>
<SchemaForm 
	action="?/subscribe"
	schema={subscribeSchema}
	data={data}
	let:saving
	let:errors
	resetOnSave
	on:validate={(event) => {
		const { data, setError } = event.detail;
		if (data.password !== data.confirmPassword) {
			setError("confirmPassword", "Passwords do not match");
		}
	}}>
	<div class="field">
		<label>Email</label>
		<input name="email" bind:value={data.email} required />
		{#if errors.has("email")}
			<span class="error">{errors.get("email")}</span>
		{/if}
	</div>
	<div class="field">
		<label>Password</label>
		<input name="password" type="password" bind:value={data.password} required />
		{#if errors.has("password")}
			<span class="error">{errors.get("password")}</span>
		{/if}
	</div>
	<div class="field">
		<label>Confirm Password</label>
		<input name="confirmPassword" type="password" bind:value={data.confirmPassword} required />
		{#if errors.has("confirmPassword")}
			<span class="error">{errors.get("confirmPassword")}</span>
		{/if}
	</div>
	<button disabled={saving}>Subscribe</button>
</SchemaForm>

Some Things to Note

Field Names for Nested Data

Both error checking and server-side data parsing require field names to be the data object path that corresponds to that field. For example, if your data is:

{
	a: {
		b: [{ c: 1 }];
	}
}

Then the field name for c should be name="a.b.{index}.c".