
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 definitions
As mentioned in the previous post, you can re-use schema definitions in two ways:
- 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
- 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
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 likePick
&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!
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.