Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
a year ago

Zod Validation: Wrappers as “Middleware”

Exciting news! There is an new way to add zod validation to your queries, mutations, and actions. Check out this post to learn more about using the zod helpers from the convex-helpers npm package.

Convex loves zod

Following up on the previous post on using withUser to add authentication context to your Convex functions, now let’s look at adding function validation using a popular npm package zod. Check out the code in action in the convex-demos repo.

Function validation is important for a production app, because you can’t always control which clients are talking to your server. Consider the following code from our tutorial:

// convex/sendMessage.js
export default mutation(async ({ db }, { body, author }) => {
  const message = { body, author };
  await db.insert("messages", message);
});

This code runs in the server, and stores a message in the “messages” table. That’s great, assuming the client sends data in the right format. Most of the time, that will be the case. If you use typescript, we’ll even warn you in your frontend React code when the parameters to your server function are the wrong type. See what this looks like in our typescript demo:

// convex/sendMessage.ts
export default mutation(
  async ({ db }, { body, author }: { body: string; author: string }) => {
    const message = { body, author };
    await db.insert("messages", message);
  }
);

However, a friendly internet stranger might connect to your backend and send any number of things: wrong types, binary data, nested objects, etc.. Typescript doesn’t enforce types at runtime, it only helps you with static analysis. What does that mean? While Typescript can help catch developer errors where you’re using types incorrectly in code, once the application is running the Typescript types aren’t being enforced by the runtime. For serverless applications that have unauthenticated endpoints, you need to be especially defensive with your function arguments, since a fake client could connect to your backend and pass whatever arguments it wants. Just declaring the type of body to be string doesn’t make it so. So what can we do?

Using Convex input validation

Update: At the original time of this post, we didn't have input validation as part of Convex. With 0.13.0 and later, however, you can add input validation like this:

export default mutation({
  args: {
    body: v.string(),
    author: v.string(),
  },
  handler: async ({ db }, { body, author }) => {
    const message = { body, author };
    await db.insert("messages", message);
  }
});

And it'll validate the types of the arguments! Keep reading to learn how to use Zod to do even more validation, for instance validating string lengths or things our syntax doesn't support (yet).

Using withZod for input validation

Using the popular zod library, we can define the types that we expect for our function. When it gets invoked, the inputs will be validated. To make this convenient, I’ve written a withZod wrapper so you can type your function arguments, and not have to worry about validating the first { db, ... } argument, which is provided by the query, mutation, or action. So now your code looks like this:

export default mutation(
  withZod({
    args: {
	  body: z.string(),
	  author: z.string(),
	},
    handler: async ({ db }, { body, author }) => {
      const message = { body, author };
      return await db.insert("messages", message);
    }
  })
);

Note: For a typescript version of everything in this post, you can look here. In there are also various helpers for combining with query, mutation, and action, as well as helpers for if you want to pass in a whole custom zod function rather than just the arguments and return type.

Aside: the above is already valid typescript!

By leveraging zod to give us validation, it is also giving us the types of our parameters. So we can avoid duplicating that definition, while still getting type hints, both in the server code and in the client. Convex has always had canonical end-to-end typing: from your data model definition to the client, it’s all in typescript. Now, the typescript types for your functions are generated from your validator, so your code is safe by default!

zId Helper

You’ll note above we used zId. This is a helper, meant to resemble s.id("messages") in your schema.ts file, but for zod. Feel free to make any other helpers you’d like and share them with us in Discord.

export const zId = (tableName: TableName) =>
  z.custom((val) => val instanceof Id && val.tableName === tableName);

Implementing withZod

For those curious, or who want to copy and extend this pattern, this is what we are doing under the hood:

export const withZod = ({ args, handler }) => {
  const zodType = z.function(z.tuple([z.object(args)]));
  return (ctx, args) => {
    const innerFunc = (validatedArgs: z.output<z.ZodObject<Args>>) =>
      handler(ctx, validatedArgs);

    return zodType.implement(innerFunc)(args);
  };
};

We are using a z.function and passing in the untrusted arguments, while just passing through the ctx argument.

Note: For a typescript version, go here.

Combining with other wrappers

As with our previous post, you can combine it with other wrappers or one of the Convex function generators to reduce duplicate code, and reduce the indentation of your function definition:

const mutationWithZod = ({ args, handler }) => mutation(withZod({ args, handler }));
export default mutationWithZod({
  args: {
    body: z.string(),
	author: z.string(),
  },
  handler: async ({ db }, { body, author }) => {
    const message = { body, author };
    return await db.insert("messages", message);
  },
});

See here for implementations of this and others in typescript.

Zod without withZod 🤯:

You can do all this yourself, without our fancy withZod wrapper, by putting your application code inside an implements or strictImplements definition for a zod function.

export default mutation(async ({ db }, { body, author }) => {
  return z
    .function()
    .args([z.object({body: z.string(), author: z.string()})])
    .returns(z.promise(z.object({ _id: zId("messages") })))
    .implement(async ({ body, author }) => {
      const message = { body, author };
      const id = await db.insert("messages", message);

      return (await db.get(id))!;
    })({ body, author });
});

In summary

In this post, we looked at a way to add type validation to Convex functions by using the zod npm package. You can grab the library code from here, or play around with a demo app here! As always, let us know in our Discord what you think!

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