Skip to content

Instantly share code, notes, and snippets.

@EBBozkurt
Last active December 18, 2025 11:13
Show Gist options
  • Select an option

  • Save EBBozkurt/e1276b2b33d88d8fa4253889bce180f2 to your computer and use it in GitHub Desktop.

Select an option

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.
/**
* 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,
};
}
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