Profile image
Ian Macartney
9 days ago

Zod Validation: Wrappers as “Middleware”

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.js
export default mutation(async ({ db }, 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 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(
    [z.string(), z.string()],
    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!

Validating returned objects

You can pass an optional third parameter to withZod to validate the output of your server code. While you don’t have to worry about malicious clients sending malformed requests, you might still enjoy the peace of mind to ensure you aren’t leaking more data than you want. You can also define a Zod object that has a subset of fields, and let Zod strip your returned object down, so you don’t share more fields than you intended.

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

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 = (zodArgs, func, zodReturn?) => {
  const zodFunctionDef = z.function(
    z.tuple(zodArgs),
    z.promise(zodReturn ?? z.unknown())
  );
  return (ctx, ...outerArgs) => {
    const zodFunction = zodFunctionDef.strictImplement((...args) =>
      func(ctx, ...args)
    );
    return zodFunction(...outerArgs);
  };
};

We are using a z.function and passing in the untrusted arguments, while just passing through the ctx argument. We use strictImplement here to define a function that adds the ctx argument back in.

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 = (zFunc, func) => mutation(withZod(zFunc, func));
export default mutationWithZod(
  [z.string(), z.string()],
  async ({ db }, body, author) => {
    const message = { body, author };
    return await db.insert("messages", message);
  },
  zId("messages")
);

See here for implementations of this and others in typescript.

A note on args_0:

Unfortunately, you lose the argument names in autocomplete when you use zod functions; they’re replaced with args_0, args_1, etc. To work around this, you have a couple of options: you can inline the zod function, or pass args as an object.

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: string, author: string) => {
  return z
    .function()
    .args(z.string(), z.string())
    .returns(z.promise(z.object({ _id: zId("messages") })))
    .strictImplement(async (body, author) => {
      const message = { body, author };
      const id = await db.insert("messages", message);

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

With this, your function arguments will still show up as “body” and “author”. One downside here is that (for typescript) you need to repeat the definition of the argument types. Once for the mutation arguments, and again for the zod function arguments.

Using an object arg for an explicit API:

You can also use our withZodObjectArg which assumes your function only takes one argument, which is an object. This allows you to write code like:

// convex/sendMessage.js
export default mutationWithZodObjectArg(
  { body: z.string(), author: z.string() },
  async ({ db }, { body, author }) => {
    const message = { body, author };
    return await db.insert("messages", message);
  },
  zId("messages")
);

// web client React code
const sendMessage = useMutation("sendMessage");
...
await sendMessage({body, author});

The client passes all arguments in a typed object, so every argument has a name. The type hint will now tell you the names of the parameters. If you enjoy wrapping your arguments in objects anyways, you’re welcome.

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!