Anjana Vakil's avatar
Anjana Vakil
a year 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:
// in convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export const recipeFields = {
  name: v.string(),
  course: v.union(
    v.literal("appetizer"),
    v.literal("main"),
    v.literal("dessert")
  ),
  ingredients: v.array(v.string()),
  steps: v.array(v.string()),
};

export default defineSchema({
  recipes: defineTable(recipeFields).index("by_course", ["course"]),
});

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:

// in convex/recipes.ts
import { mutation } from "./_generated/server";
import { recipeFields } from "./schema";

export const addRecipe = mutation({
  args: recipeFields,
  handler: async (ctx, args) => {
    return await ctx.db.insert("recipes", args);
  },
});
  1. The second way of re-using the schema validators is to access them through the schema object directly:
import schema from "./schema";

const recipeFieldValidators = schema.tables.recipes.validator;
const courseValidator = recipeFieldValidators.fields.course;

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:

// in src/NewRecipePage.tsx

import { api } from "../convex/_generated/api";
import type { Doc } from "../convex/_generated/dataModel";
import type { WithoutSystemFields } from "convex/server";

export function SaveRecipeButton({
  recipeData,
}: {
  recipeData: WithoutSystemFields<Doc<"recipes">>;
}) {
  const createRecipe = useMutation(api.recipes.create);
  const onClick = () => createRecipe(recipeData);
  return <button onClick={onClick}>Save recipe</button>;
}

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:

import { recipeFields } from "./schema";
import { internalQuery, internalMutation } from "./_generated/server";
import { v, type Infer } from "convex/values";

const vFindRecipeArgs = v.object({
  name: v.string(),
  course: recipeFields.course,
});

export const findRecipe = internalQuery({
  args: vFindRecipeArgs,
  handler: async (ctx, args) => findRecipeHandler,
});

// This is a normal typescript function
async function findRecipeHandler(
  ctx: QueryCtx,
  args: Infer<typeof vFindRecipeArgs>
) {
  await ctx.db.query("recipes");
  //.. rest of function
}

export const updateName = internalMutation({
  args: {
    oldName: v.string(),
    newName: v.string(),
    course: recipeFields.course,
  },
  handler: async (ctx, args) => {
    const existing = await findRecipeHandler(ctx, {
      name: args.oldName,
      course: args.course,
    });
    if (!existing) throw new Error("couldn't find recipe!");
    await ctx.db.patch("recipes", existing._id, { name: args.newName });
  },
});

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:

import { v, type ObjectType } from "convex/values";

const vFindRecipeArgs = {
  name: v.string(),
  course: recipeFields.course,
};

type FindRecipeArgs = ObjectType<typeof vFindRecipeArgs>;

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:

// in src/Cookbook.tsx
type RecipeSummary = Pick<Doc<"recipes">, "name" | "course">;
type UncategorizedRecipe = Omit<Doc<"recipes">, "course">;

function RecipeHeader({ recipe }: { recipe: RecipeSummary }) {
  return (
    <h1>
      {recipe.name} ({recipe.course})
    </h1>
  );
}

function RecipeDetails({ recipe }: { recipe: UncategorizedRecipe }) {
  return (
    <p>
      {recipe.name}: {recipe.steps.length} steps
    </p>
  );
}

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:

// in convex/recipes.ts
import { query } from "./_generated/server";
import { recipeFields } from "./schema";

// Pick equivalent:
const { course } = recipeFields;

export const findRecipesByCourse = query({
  args: { course },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("recipes")
      .withIndex("by_course", (q) => q.eq("course", args.course))
      .collect();
  },
});

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:

// in convex/recipes.ts
import { mutation } from "./_generated/server";
import { recipeFields } from "./schema";

const { course, ...recipeWithoutCourse } = recipeFields;

export const addDessert = mutation({
  args: recipeWithoutCourse,
  handler: async (ctx, args) => {
    return await ctx.db.insert("recipes", { ...args, course: "dessert" });
  },
});

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

import { pick, omit } from "convex-helpers";

const courseAndName = pick(recipeFields, ["course", "name"]);

const recipeWithoutCourse = omit(recipeFields, ["course"]);

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:

import type { Id, Doc } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";

async function updateRecipe(
  ctx: MutationCtx,
  recipeId: Id<"recipes">,
  update: Partial<Doc<"recipes">>
) {
  await ctx.db.patch("recipes", recipeId, update);
}

// Somewhere in a mutation handler...
await updateRecipe(ctx, recipeId, { course: "dessert" });

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:

// in convex/recipes.ts
import { partial } from "convex-helpers/server/validators";
import schema from "./schema";
import { mutation } from "./_generated/api";

const recipeValidator = schema.tables.recipes.validator;

export const update = mutation({
  args: {
    recipeId: v.id("recipes"),
    update: partial(recipeValidator),
  },
  handler: async (ctx, args) => {
    await updateRecipe(ctx, args.recipeId, args.update);
  },
});

// Somewhere on the client:

await convex.mutation(api.recipes.update, {
  recipeId,
  update: { course: "dessert" },
});

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

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:

import { systemFields, withSystemFields } from "convex-helpers/validators";

const fullValidator = {
  ...schema.tables.recipes.validator.fields,
  ...systemFields("recipes"),
};

// equivalent:
const fullValidator = withSystemFields(
  "recipes",
  schema.tables.recipes.validator.fields
);

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:

import schema from "./schema";
import { doc } from "convex-helpers/validators";

export const gradeRecipe = mutation({
  args: {
    recipe: doc(schema, "recipes"),
  },
  handler: async (ctx, args) => {
    console.log(args.recipe._id, args.receipe.course);
  },
});

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.

// in convex/schema.ts
const schema = defineSchema({ ... });
export const vv = typedV(schema);

// in convex/foo.ts
import { vv } from "./schema";

export const getRecipe = mutation({
  args: {
      recipeId: vv.id("reciepes") // <- this will be a type error
  },
    returns: vv.doc("recipes"),
    handler: async (ctx, args) => ctx.db.get("recipes", args.recipeId),
}

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!

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started