Thilo Maier

Complement zero-effort type safety in SvelteKit with Zod for even more type safety

Thilo Maier •

SvelteKit introduced generated types a while ago. SvelteKit would automatically generate types for data and form in +page.svelte/+layout.svelte files and load functions and request handlers in +page.js/+layout.js, +page.server.js/+layout.server.js and +server.js files. But you had to annotate the types yourself, which felt like a repetitive chore:

/** @type {import('./$types').PageLoad} */
export async function load({ fetch, params }) {
  // `fetch` and `params` are typed.
}
JSDoc type annotation in +page.js.
<script>
  /** @type {import('./$types').PageData} */
  export let data;

  // `title`, `description`, and `topic` are typed:
  const { title, description, topics } = data;

  //...
</script>
Another JSDoc type annotation in +page.svelte.

Yesterday, the SvelteKit team went one step further and introduced zero-effort type safety for crucial parts of a SvelteKit app. This improvement makes manual annotations of generated types obsolete. You get type safety for data flowing through your SvelteKit app without TypeScript annotations. I removed a big chunk of my JSDoc annotations from my website in this pull request without losing any type safety. You can read more about zero-effort type safety in the announcement post.

Zero-effort type safety is a massive improvement for developer happiness. However, it would be best to put in additional effort to achieve complete type safety for your SvelteKit app. Besides data flowing through your SvelteKit app, you also need to keep an eye on all the points where data enters your SvelteKit app.

Validation with Zod

There are three ways for data to enter your SvelteKit app:

  1. By fetching data from the local file system, e.g., reading a Markdown file with a blog post or reading a JSON file with permitted tags.
  2. By fetching data from an external API, e.g., a headless CMS or a third-party API.
  3. By processing data submitted through a web form.

All three scenarios have in common that type annotations of incoming data are moot when the data you get is not what you expected. What you need is proper validation.

Zod is a schema validation library with first-class TypeScript support. The first thing to note is that most data coming into your app is structured, i.e., a combination of objects and arrays that contain strings. E.g., on my website, every post is in a Markdown file. Its frontmatter has a structure that can be described with a Zod schema:

import { z } from 'zod';

export default z.object({
  title: z.string(),
  author: z.string(),
  description: z.string(),
  published: z.string().datetime(),
  topics: z.array(z.string()).optional(),
  tags: z.array(z.string()).optional(),
});
src/lib/schemas/post-schema.js

Every property is required by default, and you can nest schemes and add additional constraints. E.g., z.array(z.string()).optional() means that the schema expects an optional array of strings.

Once you have a schema, you can validate incoming data against it. In this example, I validate the frontmatter of a post against the Zod schema in a helper function:

const result = PostSchema.safeParse(post.metadata);

if (!result.success) {
  throw new Error(`Frontmatter of file ${path} failed validation.`);
}

const frontmatter = result.data;
const slug = slugify(frontmatter.title);
Frontmatter validation in src/lib/posts.js.

Nothing spectacular except that frontmatter, which is the validated data, is now typed:

Screenshot of the code snippet from above in Visual Studio Code. The mouse hovers over `frontmatter.title`, and a pop-up shows the type `string` and the description from the `PostSchema` Zod schema.
Zod infers the type of validated data from the schema.

Not only does Zod validate the data, but it also types it and gives you full type safety for whatever you do with the validated data.

Validating and typing data from an API with Zod

To drive this point home, let's look at another example in a form action:

const res = await get_subscriber(validated_data.email_address);

if (!res.ok) {
  throw error(500, 'Subscription failed.');
}

// Validate subscriber.
const result = EOSubscriberSchema.safeParse(await res.json());

if (!result.success) {
  throw error(500, 'Subscription failed.');
}

const subscriber = result.data;
Validating data retrieved from an API in +page.server.js.

This form action handles data from a newsletter subscription form. At this point in the handler, I know that the subscriber already exists, and I try to look up their status with API helper get_subscriber. I validate the API response against Zod schema EOSubscriberSchema.

If the validation fails, the external API did not return subscriber info in the expected format, and I throw a server error. If the validation succeeds, the subscriber variable is typed:

Screenshot of the code snippet from above in Visual Studio Code. The mouse hovers over the validated `subscriber` variable, and a pop-up shows the object type inferred from the `EOSubscriber` Zod schema.
The subscriber variable is validated and typed, thanks to Zod's type inference.

Conclusion

SvelteKit's zero-effort types provide type safety when data flows through your app. You should complement zero-effort type safety with Zod schemas and validate data whenever it enters your app. Zod's type inference gives you complete type safety for validated data.