TypeScript checks types at compile time, not at runtime. As soon as data comes from the outside, like a form or an API response, nothing guarantees it has the shape you expect. Zod fills that gap: you describe a schema once, and derive both the validation and the type from it.

One schema, two uses

import { z } from 'zod';

export const contactSchema = z.object({
    email: z.string().email('Invalid email address'),
    message: z.string().min(10, 'Message too short').max(1000),
});

export type Contact = z.infer<typeof contactSchema>;

Contact is inferred from the schema: it can't drift away from the validation.

In a SvelteKit form action

import { fail } from '@sveltejs/kit';
import { contactSchema } from '$lib/schemas';
import type { Actions } from './$types';

export const actions: Actions = {
    default: async ({ request }) => {
        const form = Object.fromEntries(await request.formData());
        const result = contactSchema.safeParse(form);

        if (!result.success) {
            return fail(400, { errors: result.error.flatten().fieldErrors });
        }

        // result.data is typed as Contact here
        return { success: true };
    },
};

Why safeParse over parse

Method Behavior on error Use
parse Throws an exception When invalid data is a bug
safeParse Returns { success: false } User input, forms

Any data that crosses a boundary of your program (a form, an HTTP request, a file, an environment variable) deserves a schema. What stays inside, already validated, can rely on TypeScript alone.

A basic rule of validation

Takeaways

  • Define one schema per form, in $lib, so it can be reused on the client
  • Display errors next to each field
  • Always validate on the server, even if the client already did

Client-side validation improves the experience, but only the server's validation truly protects you.