Last active
December 18, 2025 11:13
-
-
Save EBBozkurt/e1276b2b33d88d8fa4253889bce180f2 to your computer and use it in GitHub Desktop.
A lightweight, type-safe form validation solution for Svelte 5 using Zod schemas. Built with Svelte 5 runes ($state, $derived) for reactive form management without external dependencies like Felte or react-hook-form.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * NOTE: This file uses .svelte.ts extension for three reasons: | |
| * 1. Runes support: Enables use of $state, $derived, $effect | |
| * 2. Type safety: Works like normal TypeScript with full type checking | |
| * 3. Easy import: Can be imported without the .svelte extension | |
| * | |
| * Regular .ts files cannot use Svelte runes - they would throw "rune_outside_svelte" error. | |
| * The .svelte.ts extension tells the Svelte compiler to process this file and enable runes. | |
| */ | |
| import type { ZodType } from 'zod'; | |
| import { ZodService, type ValidationErrors } from '@core/zod/zod.service'; | |
| export interface UseFormOptions<T> { | |
| schema: ZodType<T>; | |
| initialValues: T; | |
| onSubmit: (values: T) => void | Promise<void>; | |
| } | |
| /** | |
| * Create a form with validation using Zod schema | |
| * Works with Svelte 5 runes | |
| * | |
| * Error display behavior: | |
| * - Errors are hidden until the user clicks Submit | |
| * - After first submit, errors show in real-time as user types | |
| * - No need for onblur handlers on each input! | |
| * | |
| * @example | |
| * ```svelte | |
| * <script lang="ts"> | |
| * import { useForm } from '@core/zod/use-form.svelte'; | |
| * | |
| * const form = useForm({ | |
| * schema: loginSchema, | |
| * initialValues: { name: '', pass: '' }, | |
| * onSubmit: async (values) => { ... } | |
| * }); | |
| * </script> | |
| * | |
| * <form onsubmit={form.handleSubmit}> | |
| * <input name="name" bind:value={form.data.name} /> | |
| * {#if form.shouldShowError('name')} | |
| * <span>{form.errors.name?.[0]}</span> | |
| * {/if} | |
| * <button disabled={form.isSubmitting || !form.isValid}>Submit</button> | |
| * </form> | |
| * ``` | |
| */ | |
| export function useForm<T extends Record<string, any>>(options: UseFormOptions<T>) { | |
| const zodService = new ZodService(); | |
| const { schema, initialValues, onSubmit } = options; | |
| // Reactive state using Svelte 5 runes | |
| let data = $state<T>({ ...initialValues }); | |
| let touched = $state<Partial<Record<keyof T, boolean>>>({}); | |
| let isSubmitting = $state(false); | |
| let hasSubmitted = $state(false); | |
| // Derived state | |
| let errors = $derived.by<ValidationErrors<T>>(() => { | |
| return zodService.validateSchema(schema, data); | |
| }); | |
| let isValid = $derived.by(() => { | |
| const result = zodService.validate(schema, data); | |
| return result.success; | |
| }); | |
| // Handlers | |
| function handleBlur(field: keyof T) { | |
| touched[field] = true; | |
| } | |
| function shouldShowError(field: keyof T): boolean { | |
| // Only show errors after form has been submitted at least once | |
| return hasSubmitted && errors[field] !== undefined; | |
| } | |
| async function handleSubmit(event: Event) { | |
| event.preventDefault(); | |
| // Mark form as submitted (this enables error display) | |
| hasSubmitted = true; | |
| // Mark all fields as touched | |
| const allTouched = Object.keys(data).reduce( | |
| (acc, key) => { | |
| acc[key as keyof T] = true; | |
| return acc; | |
| }, | |
| {} as Partial<Record<keyof T, boolean>> | |
| ); | |
| touched = allTouched; | |
| // Validate | |
| const result = zodService.validate(schema, data); | |
| if (!result.success) { | |
| // Errors will now be visible because hasSubmitted = true | |
| return; | |
| } | |
| // Submit | |
| isSubmitting = true; | |
| try { | |
| await onSubmit(result.data!); | |
| } finally { | |
| isSubmitting = false; | |
| } | |
| } | |
| function reset() { | |
| data = { ...initialValues }; | |
| touched = {}; | |
| isSubmitting = false; | |
| hasSubmitted = false; | |
| } | |
| function setFieldValue(field: keyof T, value: any) { | |
| data[field] = value; | |
| } | |
| function setFieldTouched(field: keyof T, isTouched: boolean = true) { | |
| touched[field] = isTouched; | |
| } | |
| // Return a flat object with all properties and methods | |
| return { | |
| // State | |
| get data() { | |
| return data; | |
| }, | |
| set data(value: T) { | |
| data = value; | |
| }, | |
| get errors() { | |
| return errors; | |
| }, | |
| get touched() { | |
| return touched; | |
| }, | |
| get isSubmitting() { | |
| return isSubmitting; | |
| }, | |
| get isValid() { | |
| return isValid; | |
| }, | |
| // Methods | |
| handleSubmit, | |
| handleBlur, | |
| shouldShowError, | |
| reset, | |
| setFieldValue, | |
| setFieldTouched, | |
| }; | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { ZodType, ZodError } from 'zod'; | |
| export type ValidationErrors<T> = Partial<Record<keyof T, string[]>>; | |
| export interface ValidationResult<T> { | |
| success: boolean; | |
| data?: T; | |
| errors?: ValidationErrors<T>; | |
| } | |
| export class ZodService { | |
| /** | |
| * Validate a schema with values | |
| * @param schema - Zod schema to validate | |
| * @param values - Values to validate | |
| * @returns The validation errors | |
| */ | |
| public validateSchema<T>(schema: ZodType<T>, values: any): ValidationErrors<T> { | |
| const result = schema.safeParse(values); | |
| return result.success ? {} : this.formatErrors<T>(result.error); | |
| } | |
| /** | |
| * Validate a schema and return detailed result | |
| * @param schema - Zod schema to validate | |
| * @param values - Values to validate | |
| * @returns Validation result with success status, data or errors | |
| */ | |
| public validate<T>(schema: ZodType<T>, values: any): ValidationResult<T> { | |
| const result = schema.safeParse(values); | |
| if (result.success) { | |
| return { | |
| success: true, | |
| data: result.data, | |
| }; | |
| } | |
| return { | |
| success: false, | |
| errors: this.formatErrors<T>(result.error), | |
| }; | |
| } | |
| /** | |
| * Validate a single field | |
| * @param schema - Zod schema to validate | |
| * @param fieldName - Field name to validate | |
| * @param value - Value to validate | |
| * @returns Error message or null | |
| */ | |
| public validateField<T>(schema: ZodType<T>, fieldName: keyof T, value: any): string | null { | |
| try { | |
| const result = schema.safeParse({ [fieldName]: value } as any); | |
| if (result.success) return null; | |
| const errors = this.formatErrors<T>(result.error); | |
| return errors[fieldName]?.[0] || null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Format Zod errors to a more usable structure | |
| * @param error - Zod error object | |
| * @returns Formatted errors | |
| */ | |
| private formatErrors<T>(error: ZodError): ValidationErrors<T> { | |
| const formatted: ValidationErrors<T> = {}; | |
| error.issues.forEach((issue: any) => { | |
| const path = issue.path[0] as keyof T; | |
| if (!formatted[path]) { | |
| formatted[path] = []; | |
| } | |
| formatted[path]!.push(issue.message); | |
| }); | |
| return formatted; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment