Build forms that validate themselves — two-way binding with bind:value, Zod schemas that check data at runtime, and schema-driven error messages on submit.
Why: bind:value keeps an input and a $state variable in sync in both directions — type and the variable updates, set the variable and the input updates. bind:checked does the same for checkboxes, bind:group for radios. Note: preventDefault stops the browser's full-page reload.
<!-- src/routes/signup/+page.svelte — lives at /signup -->
<script lang="ts">
let email = $state('')
let submitted = $state(false)
</script>
{#if submitted}
<!-- After submit, greet the user by the part before the @ -->
<p>Welcome, {email.split('@')[0]}</p>
{:else}
<form
onsubmit={(e) => {
e.preventDefault() // stop the browser's full-page reload
submitted = true
}}
>
<!-- bind:value = two-way sync between input and variable -->
<input type="email" bind:value={email} placeholder="you@example.com" />
<button disabled={!email.includes('@')}>Sign up</button>
</form>
{/if}Why: TypeScript types vanish at runtime — fetch responses and form input are typed as whatever you claim. A Zod schema actually checks the data, and z.infer derives the static type from it so you never write the shape twice.
$ pnpm add zodimport { z } from 'zod'
// The schema both describes the shape and checks real data against it
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member']).default('member'),
})
type User = z.infer<typeof UserSchema> // static type, derived for free
async function loadUser(): Promise<User | null> {
const raw = await fetch('/api/me').then((r) => r.json())
// .safeParse returns a result object; .parse throws instead
const result = UserSchema.safeParse(raw)
if (!result.success) {
console.log(result.error.issues)
return null
}
return result.data // fully typed as User
}Why: run the whole form through the schema on submit and show the messages it produces — one schema drives the types, the rules, and the error text. No form library needed for small forms.
<script lang="ts">
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
})
let email = $state('')
let password = $state('')
let errors = $state<{ email?: string[]; password?: string[] }>({})
function onsubmit(e: SubmitEvent) {
e.preventDefault()
const result = schema.safeParse({ email, password })
if (!result.success) {
// One error list per field, straight from the schema
errors = result.error.flatten().fieldErrors
return
}
errors = {}
console.log(result.data) // fully typed and checked
}
</script>
<form {onsubmit}>
<input bind:value={email} placeholder="Email" />
<!-- The message comes straight from the schema above -->
{#if errors.email}<p>{errors.email[0]}</p>{/if}
<input bind:value={password} type="password" placeholder="Password" />
{#if errors.password}<p>{errors.password[0]}</p>{/if}
<button>Log in</button>
</form>