Fullstack validation with Zod

Building web applications with complex business logic can be a challenging task. Ensuring that user inputs are validated correctly, data is formatted consistently, and that edge cases are handled properly is crucial for the health of a web application. That's where validation libraries come in. In this blog post, we'll explore Zod, a powerful TypeScript-first validation library, and how it can help us achieve full-stack validation for our web applications. Here is an example for validating addresses.

import { z } from 'zod';

const AddressSchema = z.object({
    street: z.string(),
    house_number: z.number().int(),
    extension: z.string().optional(),
    postalcode: z.string()
});

AddressSchema.parse(address);

Type inference

Zod's type inference is a powerful tool for TypeScript developers. It allows you to declare a validator once and Zod will automatically infer the static TypeScript type. This eliminates the need to keep static types and schemas in sync, which can be tedious and time consuming. With Zod, it's easy to compose simpler types into complex data structures that are both type-safe and validated at runtime. Notice the address schema being used within the user schema.

const UserSchema = z.object({
    id: z.string().uuid(),
    name: z.string().min(3).max(20),
    address: AddressSchema.optional()
});

type User = z.infer<typeof UserSchema>;

Derived schemas

It is also possible to derive schemas from existing schemas. This is useful when you want to create a schema that is similar to an existing schema, but with some fields being optional or required. An example of this would be an update user schema. All fields except for the id field should be optional.

const UpdateUserSchema = UserSchema.partial().required({
    id: true
});

type UpdateUser = z.infer<typeof UpdateUserSchema>;

Validation

Zod provides two methods to use schema’s to validate data. The first is the parse method, if the validation fails this method throws an error containing details on what failed.

// Will throw an error containing details on what failed
UserSchema.parse({});

The second method will return an object containing either the parsed object or the error information depending on whether the parsing was successfull.

const parsed = UserSchema.safeParse(user);

if (!parsed.success) {
    throw parsed.error;
}

// user is valid, parsed.data contains user including any transformations done by schema
console.log(parsed.data);

Bringing it all together

You can find the fully functional demo here on StackBlitz.

We have seen how Zod can be used to validate data, but how do we tie this all together to share validation logic between the frontend and backend? The answer is to use a shared schema. This schema can be used to validate data on the frontend and backend. This ensures that the data is validated consistently and that the validation logic only needs to be written once.

The following example uses SvelteKit as the frontend framework, but the same principles apply to any frontend framework. The form validation library used is Felte, which can be used with Svelte, React and SolidJS. Felte supports using Zod schemas for validation.

We'll be implementing a form to update a user's email address. The user's current email address is shown on the page. The user can enter their new email address and repeat it to confirm. The form will be validated on the frontend using Felte and on the backend using Zod.

First we declare the Zod schema that will be used to validate the form data on the frontend as well as the backend.

export const UpdateEmailSchema = z
    .object({
        email: z.string().email('Invalid email'),
        repeat: z.string().email('Invalid email')
    })
    // Custom validator to check if emails match
    .superRefine((data, ctx) => {
        if (data.email !== data.repeat) {
            ctx.addIssue({
                code: 'custom',
                message: 'Emails do not match',
                path: ['repeat']
            });
        }
    });

export type UpdateEmail = z.infer<typeof UpdateEmailSchema>;

Next we create the form using SvelteKit and Felte. The form will be validated on submit. If the validation succeeds, the form data will be sent to the backend. If the validation fails, the errors will be shown to the user.

<script lang="ts">
    // imports omitted for brevity
    const { form, touched, errors } = createForm<UpdateEmail>({
        extend: validator({ schema: UpdateEmailSchema }),
        onSuccess: applyFormActionResponse,
        onError: applyFormErrorResponse
    });
</script>

<form use:form method="post">
    <label for="email">New e-mail</label>
    <input type="email" id="email" name="email" />

    {#if $touched.email && $errors.email}
        <span class="error">{$errors.email[0]}</span>
    {/if}

    <label for="repeat">Repeat</label>
    <input type="email" id="repeat" name="repeat" />

    {#if $touched.repeat && $errors.repeat}
        <span class="error">{$errors.repeat[0]}</span>
    {/if}

    <button type="submit">Update</button>
</form>

The form will be submitted to the svelte page action handler. The action handler will validate the data using Zod. If the validation succeeds, the data will be saved to the database and the user will be redirected to the homepage. If the validation fails, the errors will be returned to the frontend and shown to the user.

// imports omitted for brevity

export const actions = {
    default: async ({ request, locals }) => {
        const formData = Object.fromEntries(await request.formData());
        const validatedSchema = UpdateEmailSchema.safeParse(formData);

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

        const service = new AccountService(locals.accountId);
        await service.updateEmail(validatedSchema.data.email);

        throw redirect(307, '/success');
    }
};

Conclusion

Zod is a powerful TypeScript-first validation library that can be used to achieve full-stack validation for web applications. It's type inference allows you to declare a validator once and Zod will automatically infer the static TypeScript type. This eliminates the need to keep static types and schemas in sync, which can be tedious and time consuming. With Zod, it's easy to compose simpler types into complex data structures that are both type-safe and validated at runtime. Zod allows us to share validation logic between the frontend and backend, ensuring that the data is validated consistently and that the validation logic only needs to be written once.

Further reading

StackBlitz demo
Zod docs
Felte docs
SvelteKit docs