Anjana Vakil's avatar
Anjana Vakil
3 months ago

Argument Validation without Repetition

Argument validation without repetition: Advanced tips and tricks

When developing Convex apps with TypeScript, you’ll achieve the fastest iteration cycle and best developer experience once you’ve streamlined how schema enforcement, argument validation, and end-to-end type hinting all work together in your app.

In the first post in this series, the Types and Validators cookbook, we shared several basic patterns & best practices for organizing your codebase to share types & validators from your schema, which becomes the central source of truth for your data model.

In this post, we’ll take it one step further and introduce a few more advanced techniques & helpers to accelerate the development workflow in your Convex projects.

Review: reuse schema definitions

As mentioned in the previous post, you can re-use schema definitions in two ways:

  1. Define validators describing your data model in your schema file, then export them for use across your codebase like so:
1// in convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export const recipeFields = {
6  name: v.string(),
7  course: v.union(
8    v.literal("appetizer"),
9    v.literal("main"),
10    v.literal("dessert")
11  ),
12  ingredients: v.array(v.string()),
13  steps: v.array(v.string()),
14};
15
16export default defineSchema({
17  recipes: defineTable(recipeFields).index("by_course", ["course"]),
18});
19

The exported object can then be used wherever you need data of the same shape, for example to validate arguments of an addRecipe mutation that inserts a new document:

1// in convex/recipes.ts
2import { mutation } from "./_generated/server";
3import { recipeFields } from "./schema";
4
5export const addRecipe = mutation({
6  args: recipeFields,
7  handler: async (ctx, args) => {
8    return await ctx.db.insert("recipes", args);
9  },
10});
11
  1. The second way of re-using the schema validators is to access them through the schema object directly:
1import schema from "./schema";
2
3const recipeFieldValidators = schema.tables.recipes.validator;
4const courseValidator = recipeFieldValidators.fields.course;
5

Reusing schema types

And to ensure your frontend is also typed accordingly, the generated Doc<table> generic type, along with handy utilities like WithoutSystemFields<document>, ensures your client-side code matches the data model defined by your schema:

1// in src/NewRecipePage.tsx
2
3import { api } from "../convex/_generated/api";
4import type { Doc } from "../convex/_generated/dataModel";
5import type { WithoutSystemFields } from "convex/server";
6
7export function SaveRecipeButton({
8  recipeData,
9}: {
10  recipeData: WithoutSystemFields<Doc<"recipes">>;
11}) {
12  const createRecipe = useMutation(api.recipes.create);
13  const onClick = () => createRecipe(recipeData);
14  return <button onClick={onClick}>Save recipe</button>;
15}
16

Using argument validator types for helper functions

Let's say you have an internalQuery and you want to re-use the handler for a mutation. You might be tempted to run ctx.runQuery to run it. Don't! Running ctx.runQuery runs an entirely new sub-transaction in a fresh environment, which means it'll be slower and not provide much benefit for the vast majority of cases.

Instead, you should break out the handler into a shared function that both can call. But how do you avoid duplicating all the validator types as TypeScript types? Using our knowledge from the previous post about separating out validators for re-use, as well as using Infer, we can separate out the args and use them for both:

1import { recipeFields } from "./schema";
2import { internalQuery, internalMutation } from "./_generated/server";
3import { v, type Infer } from "convex/values";
4
5const vFindRecipeArgs = v.object({
6  name: v.string(),
7  course: recipeFields.course,
8});
9
10export const findRecipe = internalQuery({
11  args: vFindRecipeArgs,
12  handler: async (ctx, args) => findRecipeHandler,
13});
14
15// This is a normal typescript function
16async function findRecipeHandler(
17  ctx: QueryCtx,
18  args: Infer<typeof vFindRecipeArgs>
19) {
20  await ctx.db.query("recipes");
21  //.. rest of function
22}
23
24export const updateName = internalMutation({
25  args: {
26    oldName: v.string(),
27    newName: v.string(),
28    course: recipeFields.course,
29  },
30  handler: async (ctx, args) => {
31    const existing = await findRecipeHandler(ctx, {
32      name: args.oldName,
33      course: args.course,
34    });
35    if (!existing) throw new Error("couldn't find recipe!");
36    await ctx.db.patch(existing._id, { name: args.newName });
37  },
38});
39

Note: one difference we see here between the args of findRecipe and updateName is that the second uses a regular javascript object { oldName: v.string() ... } and the first uses v.object({ name: v.string() .... Infer only works on validators (v.*). If you want to get the type of a regular object with validator fields, you can use ObjectType:

1import { v, type ObjectType } from "convex/values";
2
3const vFindRecipeArgs = {
4  name: v.string(),
5  course: recipeFields.course,
6};
7
8type FindRecipeArgs = ObjectType<typeof vFindRecipeArgs>;
9

Selecting specific fields

Putting TS utility types to work

Sometimes you only want to work with a specific subset of the fields for a given table. For types, you can use TypeScript’s builtin Pick and Omit utility types to specify exactly which fields are needed:

1// in src/Cookbook.tsx
2type RecipeSummary = Pick<Doc<"recipes">, "name" | "course">;
3type UncategorizedRecipe = Omit<Doc<"recipes">, "course">;
4
5function RecipeHeader({ recipe }: { recipe: RecipeSummary }) {
6  return (
7    <h1>
8      {recipe.name} ({recipe.course})
9    </h1>
10  );
11}
12
13function RecipeDetails({ recipe }: { recipe: UncategorizedRecipe }) {
14  return (
15    <p>
16      {recipe.name}: {recipe.steps.length} steps
17    </p>
18  );
19}
20

Validators for specific fields

Similar to Pick in TypeScript, object "de-structuring" helps you derive subsets of the validators exported from your schema, and use these to validate function arguments:

1// in convex/recipes.ts
2import { query } from "./_generated/server";
3import { recipeFields } from "./schema";
4
5// Pick equivalent:
6const { course } = recipeFields;
7
8export const findRecipesByCourse = query({
9  args: { course },
10  handler: async (ctx, args) => {
11    return await ctx.db
12      .query("recipes")
13      .withIndex("by_course", (q) => q.eq("course", args.course))
14      .collect();
15  },
16});
17

And analogous to TypeScript’s Omit, combining de-structuring with the rest operator (...) lets you ignore certain field validators and work with just those that remain:

1// in convex/recipes.ts
2import { mutation } from "./_generated/server";
3import { recipeFields } from "./schema";
4
5const { course, ...recipeWithoutCourse } = recipeFields;
6
7export const addDessert = mutation({
8  args: recipeWithoutCourse,
9  handler: async (ctx, args) => {
10    return await ctx.db.insert("recipes", { ...args, course: "dessert" });
11  },
12});
13

To make this easy, there are pick and omit helpers:

1import { pick, omit } from "convex-helpers";
2
3const courseAndName = pick(recipeFields, ["course", "name"]);
4
5const recipeWithoutCourse = omit(recipeFields, ["course"]);
6

Handling partial updates

Sometimes you want to deal with an object where some fields are set and others are undefined. For instance a function that does a "patch" on a document, and can take in an object with the fields to update.

Partial types

For types, you can use TypeScript’s builtin Partial to make an all-fields-optional type for those cases where you’re not sure which subset of a document might be needed. For example:

1import type { Id, Doc } from "./_generated/dataModel";
2import type { MutationCtx } from "./_generated/server";
3
4async function updateRecipe(
5  ctx: MutationCtx,
6  recipeId: Id<"recipes">,
7  update: Partial<Doc<"recipes">>
8) {
9  await ctx.db.patch(recipeId, update);
10}
11
12// Somewhere in a mutation handler...
13await updateRecipe(ctx, recipeId, { course: "dessert" });
14

Partial validators

To achieve the same thing with validators requires creating an object wrapping all fields with v.optional so there is a helper to make it easy:

1// in convex/recipes.ts
2import { partial } from "convex-helpers/server/validators";
3import schema from "./schema";
4import { mutation } from "./_generated/api";
5
6const recipeValidator = schema.tables.recipes.validator;
7
8export const update = mutation({
9  args: {
10    recipeId: v.id("recipes"),
11    update: partial(recipeValidator),
12  },
13  handler: async (ctx, args) => {
14    await updateRecipe(ctx, args.recipeId, args.update);
15  },
16});
17
18// Somewhere on the client:
19
20await convex.mutation(api.recipes.update, {
21  recipeId,
22  update: { course: "dessert" },
23});
24

Note: the partial helper can take in and return a regular object ({ name: v.string() }) or a v.object({ name: v.string() }).

Helpers for schema definition & validation

get-convex/convex-helpers

The convex-helpers library has a number of utilities under convex-helpers/validators.

Working with system fields

By default, schema.tables.recipes.validator.fields will not have the _id and _creationTime system fields. To get them, we can use withSystemFields:

1import { systemFields, withSystemFields } from "convex-helpers/validators";
2
3const fullValidator = {
4  ...schema.tables.recipes.validator.fields,
5  ...systemFields("recipes"),
6};
7
8// equivalent:
9const fullValidator = withSystemFields(
10  "recipes",
11  schema.tables.recipes.validator.fields
12);
13

Validate a full object

Manually dealing with system fields can be a chore, so the doc validator makes a validator for all the fields and system fields in one call:

1import schema from "./schema";
2import { doc } from "convex-helpers/validators";
3
4export const gradeRecipe = mutation({
5  args: {
6    recipe: doc(schema, "recipes"),
7  },
8  handler: async (ctx, args) => {
9    console.log(args.recipe._id, args.receipe.course);
10  },
11});
12

Type-safe v.id validation

Using the typedV utility, we can make a version of v that has a type-safe version of v.id. It also has a v.doc similar to the doc helper above, to validate a full object.

For clarity, we'll call it vv here to differentiate from v.

1// in convex/schema.ts
2const schema = defineSchema({ ... });
3export const vv = typedV(schema);
4
5// in convex/foo.ts
6import { vv } from "./schema";
7
8export const getRecipe = mutation({
9  args: {
10      recipeId: vv.id("reciepes") // <- this will be a type error
11  },
12    returns: vv.doc("recipes"),
13    handler: async (ctx, args) => ctx.db.get(args.recipeId),
14}
15

Note: when defining schemas you need to use the default v.id since the typedV depends on the schema.

Recap: DRY validators & types

With a few TS & JS builtins and convex-helpers utilities, you can streamline your argument validator definitions, minimizing repetition across your Convex codebase.

  • Expose your tables’ field validators defined in convex/schema.ts for use in functions
  • Use Convex generic types like Doc , and TS utility types like Pick & Omit, to get the (sub)sets of fields you need in TypeScript
  • Use object destructuring & the rest operator (...) to get the (sub)sets of fields you need for argument validation in Convex functions
  • Use the validator helpers from convex-helpers for easy navigation of system fields, validators from schemas, and more.

If you found these tips useful, or have any of your own you’d like to share with other developers in the Convex community, please jump on Discord and let us know!

Build in minutes, scale forever.

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

Get started