Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
4 months ago

Zod with TypeScript for Server-side Validation and End-to-End Types

Use zCustomQuery to add zod argument validation

Want to use Zod with your TypeScript project to validate function arguments? Read on to see how, along with resources to allow you to specify Zod validation for Convex server function arguments. Want to jump right into code? It’s down here.

What is Zod?

Zod by their own definition is:

TypeScript-first schema validation with static type inference

It lets you define the format of data and validate it. It’s often used for form validation on a website or argument validation on a server. It can be used in JavaScript, but a big benefit comes from providing great TypeScript types to avoid duplicating a type definition from a validation specification.

For terminology, I’m using the term “validate” here. They like to say “parse, don’t validate,” but that nuance isn’t important to how we’ll talk about it.1 The important thing is you have:

const untrustedData: any;
const trustedData = z.string().email().parse(untrustedData);

If your untrustedData is an email, hooray! You have a safe value to use. If not, it will throw a ZodError which, in the case of form validation, you can catch to inform the user which field is invalid.

Why use Zod?

Zod allow you to:

  • Validate types at runtime: remember that defining TypeScript types doesn’t guarantee that the values at runtime will match! Especially when receiving JSON payloads, it’s important to ensure the data matches your expectation.
  • Avoid repeating type definitions and data validators.
  • Do the same runtime validation on the client and server: doing it on the client allows you to give the user quick feedback, and doing it on the server guards against malformed requests and untrusted clients.

How to use Zod

You can install Zod:

npm i zod

Then define your schema, like:

const myData: z.object({
	email: z.string().email(),
  num: z.number().min(0),
  bool: z.boolean().default(false),
  array: z.array(z.string()),
  object: z.object({ a: z.string(), b: z.number() }),
  union: z.union([z.string(), z.number()]),
});

And parse (validate) the data:

// Throws when the data is invalid
const result = myData.parse(untrustedData);
// Returns an error object instead
const { success, error, data } = myData.safeParse(untrustedData);

Using Zod for argument validation server-side

When you want to validate your endpoint’s arguments, you can do it manually on the data passed in by your framework. However, it’s more powerful to expose this data, so it can also inform end-to-end type safety, for instance with tRPC or Convex.

Using Zod with tRPC

For tRPC projects, you can provide a Zod object to .input:

const t = initTRPC.create();
 
const appRouter = t.router({
  greeting: t.publicProcedure
    .input(z.object({ name: z.string() }))
    .query((opts) => {
      const { input } = opts;
      return `Hello ${input.name}`;
  }),
});

Using Zod with Convex

With Convex, argument validation is usually done with the same validators used to define your database schema (note the v instead of z!):

export const greeting = query({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    return `Hello ${args.name}`;
  }
});

However, if you’re already using Zod elsewhere in your project, or want to validate more refined types, wouldn’t it be nice to validate your arguments with the same object? To make this possible, I wrote some helpers.

npm i convex-helpers@latest

These build off of this post where I show how to customize functions generally. See that post for the details on the API and why it’s preferable to typical middleware.

If you aren’t doing any customization and just want to use Zod arguments, you can use the functions exported from convex-helpers/server/zod:

import { z } from "zod";
import { NoOp } from "convex-helpers/server/customFunctions";
import { zCustomQuery } from "convex-helpers/server/zod";
import { query } from "./_generated/server";

// Make this once, to use anywhere you would have used `query`
const zQuery = zCustomQuery(query, NoOp);

export const greeting = zQuery({
  args: { name: z.string() },
  handler: async (_ctx, args) => {
    return `Hello ${args.name}`;
  },
});

Let’s walk through what’s happening:

  1. First we make zQuery, which is like query but modified by zCustomQuery to accept Zod arguments, along with any customization you provide in the second argument. We’re passing NoOp which doesn’t modify the defined query endpoint’s arguments (a.k.a. no-op or identity function).
  2. We use zQuery like we’d normally use query , but this time we get to pass in zod validators for arguments.

Internally, this does two things:

  1. It turns the Zod validator into a Convex validator using zodToConvex. This allows Convex to know generally what types the function expects (which helps suggest arguments when you run your functions from the Dashboard). This also allows Convex to validate the v.id("tablename") type, which ensures IDs match the expected table name.
  2. It runs the Zod validator before the function runs, since Zod types can be more specific than Convex types (e.g. z.string().email() vs. v.string()).
Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started

v.id(tablename)zid(tablename)

When you want to validate a Convex Document ID, since Zod doesn’t have a built-in type, you can use zid:

...
import { zCustomQuery, zid } from "convex-helpers/server/zod";

const zQuery = zCustomQuery(query, NoOp)

export const getUser = zQuery({
  args: {userId: zid("users")},
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);
    return user && { id: user._id, name: user.name };
  },
});

This creates a v.id validator under the hood, which ensures you don’t return data from the wrong table if someone passes an ID to another table to getUser.

However, note that zid doesn’t do the table name validation when you do .parse(). It does this by converting it into a v.id which is passed to the Convex argument validation. So keep in mind that if you’re validating a zid in a browser, it will only check that it is a string.

Output validation

In addition to validating function inputs, you can also use Zod to validate the output data, similar to defining an explicit TypeScript return type on your function. While this is less critical, since the data you return is generally more trusted than what’s provided by a user, it does have a nice benefit of limiting what you return. If your return TypeScript type is { name: string } and you return a User: { name: string, email: string }, then TypeScript will say it’s ok, but you would be leaking the user’s email, since TypeScript’s typing on objects isn’t exact. By specifying a Zod validator for your output, it will both set the TypeScript return type, but also strip out fields that you don’t specify. For example:

const user = { name: "Sam", email: "sam@example.com" };
const output = z.object({ name: z.string() });
const limited = output.parse(user);
console.log(limited)
// { name: 'Sam' }

To do this you could just do it in your function:

export const getUser = zQuery({
  args: {userId: zid("users")},
  handler: async (ctx, args) => {
    const user = (await ctx.db.get(args.userId))!;
    const output = z.object({ name: z.string() });
    return output.parse(user);
  },
});

But with the zCustomQuery , zCustomMutation, or zCustomAction, you can specify it like:

export const getUser = zQuery({
  args: {userId: zid("users")},
  handler: async (ctx, args) => {
    const user = (await ctx.db.get(args.userId))!;
    return user;
  },
  output: z.object({ name: z.string() }),
});

Note: zid won’t do table name validation on return types at this time.

Customizing the function

Similar to customFunctions described here, you can customize zCustomFunction.

Here we modify the ctx object passed into greeting:

import { z } from "zod";
import { customCtx } from "convex-helpers/server/customFunctions";
import { zCustomQuery } from "convex-helpers/server/zod";
import { query } from "./_generated/server";
import { getUser } from "./users";

const userQuery = zCustomQuery(
  query,
  customCtx(async (ctx) => {
    const user = await getUser(ctx);
    return { user };
  })
);

export const greeting = userQuery({
  args: { greeting: z.string() },
  handler: async (ctx, args) => {
    return `${args.greeting} ${ctx.user.name}`;
  },
});

Here we add session to the ctx , stripping off sessionId from the arguments:

const zQuery = zCustomQuery(query, {
  args: { sessionId: v.id("sessions") },
  input: async (ctx, args) => {
    const session = await ctx.db.get(args.sessionId);
    return { ctx: { ...ctx, session }, args: {} };
  },
});

Note: we use normal Convex validators (v.) for the customization, so it is easy to extend behavior with helpers that don’t use Zod.

Error handling

If an error occurs, it will throw a ConvexError with { ZodError } as the data. This allows you to inspect the full error object on the client-side, without leaking the server’s stack trace.

Can I use Zod to define my database types too?

With the zodToConvex function, you can turn your Zod validators into Convex types that not only work in argument validation, but also can be used to define tables (via defineTable, see here). Is this a good idea? It depends.

The data at rest is guaranteed to match the defined schema (assuming you have schema enforcement turned on). However, if your Zod type is more refined (like a z.string().email()), then there isn’t a guarantee that the data at rest matches it. For some types, like z.tuple, the definition looks more like z.array(z.union([...])).

So what can you do?

  • You can wrap ctx.db in mutations to validate the more specific data types before writing data, using a wrapper like convex-helpers/server/rowLevelSecurity. This can ensure the new data you’re writing is the right format.
  • You can wrap ctx.db in queries & mutations to validate the data on reads. This will ensure the data you retrieve is the right format. It’s an open question how you should handle invalid data on reads. If you change your schema and read an old invalid value, you could fail the request, omit the result, or try to transform it into the right shape. This is pretty messy.
  • You can run a migration over your data, validating the Zod schema when you change it. Similar to other migrations, I’d suggest first changing the data writers to validate on insert, then run a migration over older documents and manually resolve errors.

Note: if a developer edits data in the dashboard or uploads data via the CLI, your validators won’t run. The validation here is best effort, and up to you.

What would I do?

I’d use Zod validation for server arguments, and trust that the server code will write valid data. If it’s very important, I’d consolidates those writes to a function where I manually validate the data & business logic before writing it. If your server is modifying a lot of data that needs to be a certain shape, consider validating it right there. If I had more specific types that I expect from the data (e.g. z.tuple), I’d use Zod on the read side to give better types while asserting the structure.

I might use zodToConvex to define the table schema from the Zod types, but I’d add a big comment block making it clear that the validation isn’t guaranteed. I do like that my table definition would be more self-documenting. An .email() is more meaningful than v.string().

In summary

We looked at using Zod to validate function arguments (and more) to provide both type safety and runtime data validation for your TypeScript projects. By using convex-helpers you can validate your Convex functions, and translate from a Zod schema to a Convex validator. If you want to see the code, you can check it out / fork it / submit a PR here:

get-convex/convex-helpers

Footnotes

  1. For those curious, the distinction they draw is that parsing returns data with the new type, whereas validation only checks the type of an object, which leaves the type unchanged from the language’s perspective.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started