Bright ideas and techniques for building with Convex.
Profile image
Anjana Vakil
2 months ago

Argument Validation without Repetition

Argument validation without repetition: Advanced tips and tricks

Argument validation without repetition

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 field declarations

As mentioned in the previous post, you can 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);
  },
});

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>;
}

Put TS utility types to work

Sometimes you only want to work with a specific subset of the fields for a given table. On the client side, 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>
  );
}

Similarly, 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. This comes in handy when patching documents, for example:

// in src/Cookbook.tsx
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import type { Id, Doc } from "../convex/_generated/dataModel";

function RecipeEditor({ recipeId: Id<'recipes'> }) {
	const updateRecipe = useMutation(api.recipes.update);
	
	// in response to some user input...
  const newData: Partial<Doc<'recipes'>> = { 
		name:  'Sweeter recipe name',
		course: 'dessert'
  });
	updateRecipe(recipeId, newData);
}

Choose validator subsets with object destructuring

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";

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" });
  },
});

Table helper for schema definition & validation

get-convex/convex-helpers

The convex-helpers library provides a convenient Table helper to codify the pattern of splitting validator fields out of table definitions.

Table accepts a fields argument defining your field validators (just like you’d pass to defineTable):

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { Table } from "convex-helpers/server"; // npm i convex-helpers

export const Recipes = Table("recipes", {
  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: Recipes.table.index("by_course", ["course"]),
});

The object returned by Table provides easy access to not only the table itself, which can then be passed to defineSchema(), but also the corresponding validators you’ve defined, with or without the system fields _id and _creationTime which are automatically added to the table’s documents:

Recipes.table; // object returned by defineTable(), passed to defineSchema()
Recipes.withoutSystemFields; // the user-defined field validators
Recipes.withSystemFields; // those validators plus _id and _creationTime
Recipes.doc; // v.object() validator for the table's docs (incl. system fields)

These can be used as needed in your Convex functions. For example, for a addRecipe mutation that inserts a new document into the table, you can use .withoutSystemFields to validate the incoming table data:

// convex/recipes.ts
import { mutation, action } from "./_generated/server";
import { Recipes } from "./schema";

export const addRecipe = mutation({
  args: Recipes.withoutSystemFields,
  handler: async (ctx, args) => {
    return await ctx.db.insert("recipes", args);
  },
});

When you need to pass a whole document in as a function argument, after getting it from the database, .doc provides the corresponding object validator. For example, say you have a generateThumbnail action to generate an AI image based on a recipe document:

// in convex/recipes.ts
import { action } from "./_generated/server";
import { internal } from "./_generated/api";

export const generateThumbnail = action({
  args: {
    recipe: Recipes.doc,
  },
  handler: async (ctx, args) => {
    const imgStorageId = await generateDallE(
      `A recipe named ${args.recipe.name} made with ` +
      args.recipe.ingredients.join(", ")
    );
    await ctx.runMutation(internal.recipes.addImage, { 
      recipeId: args.recipe._id,
      imgStorageId,
    });
  },
});

.doc vs. .withSystemFields: A quick note on v.object()

While .withSystemFields is a regular old object (which just happens to have field names as keys and Validators as values), .doc provides the ObjectValidator corresponding to the .withSystemFields object.

In other words, Recipes.doc is equivalent to v.object(Recipes.withSystemFields).

// To accept a whole document as an argument:
args: {
    recipe: Recipes.doc, // <- you can't pass Recipes.withSystemFields here, as each arg expects a validator, not an object
}

// To accept each of the fields as separate arguments:
args: Recipes.withSystemFields // <- you can't pass Recipes.doc here, as args expects an object, not a validator

You can use the .doc validator when passing entire documents as function arguments, and .withSystemFields when you need a JS object, for instance to destructure or spread arguments as described earlier. It’s currently not possible to “unwrap” a v.object() to get the individual field validators, though that might be supported in the future.

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 Table helper from convex-helpers for easy access to the defined table and the corresponding validators for its documents, with or without system fields

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 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