
Types and Validators: A Convex Cookbook

As you become a seasoned Convex developer, you’ll see first-hand how fantastic the developer experience becomes when you’ve got types on your side. The end-to-end TypeScript as you build, and the consistency and security you get from Convex schema enforcement and argument validation at runtime give you the tools to develop safely with types to help catch bugs early.
However, if you don’t know some of the tricks we’ll show you, your code may feel cumbersome to write. For example, say you’re building a cookbook app and have defined a recipes
table in your schema. You specify the table’s fields using validators accessed from the v
object exposed by convex/values
:
1// convex/schema.ts
2import { v } from "convex/values";
3import { defineSchema, defineTable } from "convex/server";
4
5export default defineSchema({
6 recipes: defineTable({
7 name: v.string(),
8 course: v.union(
9 v.literal('appetizer'),
10 v.literal('main'),
11 v.literal('dessert')
12 ),
13 ingredients: v.array(v.string()),
14 steps: v.array(v.string())
15 }).index("by_course", ["course"]),
16});
17
Your function to add a new recipe argument validators might look like:
1// in convex/recipes.ts
2import { v } from "convex/values";
3import { mutation } from "./_generated/server";
4
5export const addRecipe = mutation({
6 args: {
7 name: v.string(),
8 course: v.union(
9 v.literal('appetizer'),
10 v.literal('main'),
11 v.literal('dessert')
12 ),
13 ingredients: v.array(v.string()),
14 steps: v.array(v.string()),
15 },
16 handler: async (ctx, args) => {
17 return await ctx.db.insert("recipes", args);
18 },
19});
20
And for a regular TypeScript function, you might find yourself defining types like:
1type Course = 'appetizer' | 'main' | 'dessert';
2
3type Recipe = {
4 name: string,
5 course: Course,
6 ingredients: string[],
7 steps: string[],
8};
9
10async function getIngredientsForCourse(recipes: Recipe[], course: Course) {
11 ...
12}
13
As you can see, you may get frustrated repeatedly defining the same validators in your schema and functions, and redeclaring similar TypeScript types in different parts of your codebase. Is there a better way? Yes!
The Convex Test Kitchen has cooked up some convenient recipes for busy fullstack chefs like you! Keep these tasty typing tricks at hand, and you’ll be whipping up the types & validators you need in no time - without any cookie-cutter repetition.
Dish out types from your DataModel
with Doc
and Id
Once you’ve defined a schema for your database (or generated one from existing data), Convex will serve up your data types on a silver platter!
Convex code generation automatically creates types for all the documents in your tables, exposed via the Doc<"tablename">
generic type from convex/_generated/dataModel
. The data model also exposes an Id<"tablename">
generic type corresponding to a valid document ID for a given table. Use these types to ensure the rest of your codebase uses data consistent with your schema:
1// in src/Cookbook.tsx
2import { useQuery } from "convex/react";
3import { api } from "../convex/_generated/api";
4import type { Doc, Id } from "../convex/_generated/dataModel";
5
6export function Cookbook() {
7 const recipes = useQuery(api.recipes.list);
8 return recipes?.map((r) => <RecipePreview recipe={r} />);
9}
10
11export function RecipePreview({ recipe }: { recipe: Doc<"recipes"> }) {
12 return (
13 <div>
14 {recipe.name} ({recipe.course})
15 </div>
16 );
17}
18
19function RecipeDetails({ id }: { id: Id<"recipes"> }) {
20 const recipe = useQuery(api.recipes.getById, { id });
21
22 return (recipe && (
23 <div>
24 <h1>{recipe.name}</h1>
25 <h2>{recipe.course}</h2>
26 <ShoppingList ingredients={recipe.ingredients} />
27 <Instructions steps={recipe.steps} />
28 </div>
29 ));
30}
31
This Id<"tablename">
type corresponds to values accepted by v.id("tablename")
:
1// in convex/recipes.ts
2import { v } from "convex/values";
3import { query } from "./_generated/server";
4
5export const getById = query({
6 args: {
7 id: v.id("recipes"),
8 },
9 handler: async (ctx, args) => {
10 return await ctx.db.get(args.id);
11 },
12});
13
Keep validators from going stale
As we’ve seen, the v
validators are used not only in your schema but also to validate arguments passed in to your Convex functions. If all you need is a single v.id
that’s no sweat, but what about when arguments should match your schema definitions? For example:
1// in convex/recipes.ts
2import { query } from "./_generated/server";
3import { v } from "convex/values";
4
5export const listByCourse = query({
6 args: {
7 course: v.union(
8 v.literal("appetizer"),
9 v.literal("main"),
10 v.literal("dessert")
11 ),
12 },
13 handler: async (ctx, args) => {
14 return await ctx.db.query("recipes")
15 .withIndex("by_course", (q) => q.eq("course", args.course))
16 .collect();
17 },
18});
19
This doesn’t smell so good; it duplicates the course
validator from your schema, which means not only did you have to repeat yourself (ugh), you also gave yourself the burden to remember to update this function whenever you update your schema (double ugh)!
To keep arguments in sync with schema changes, refactor convex/schema.ts
to first define and export your field validators, then use them to define your tables:
1// convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export const courseValidator = v.union(
6 v.literal('appetizer'),
7 v.literal('main'),
8 v.literal('dessert')
9);
10
11export default defineSchema({
12 recipes: defineTable({
13 name: v.string(),
14 course: courseValidator,
15 ingredients: v.array(v.string()),
16 steps: v.array(v.string()),
17 }).index("by_course", ["course"]),
18});
19
Now you can reuse those validators in your Convex functions as needed:
1// in convex/recipes.ts
2import { query } from "./_generated/server";
3import { courseValidator } from "convex/schema.ts";
4
5export const listByCourse = query({
6 args: {
7 course: courseValidator
8 },
9 handler: async (ctx, args) => {
10 return await ctx.db.query("recipes")
11 .withIndex("by_course", (q) => q.eq("course", args.course)
12 .collect();
13 },
14});
15
This keeps data consistent throughout your entire backend.
Note: in some helpers you'll see the pattern of prefixing validators with "v" like "vCourse" instead of "courseValidator".
Reusing validators using schema
If you prefer to keep your schema unified, you can alternatively access field validators through your schema object directly:
1// in convex/recipes.ts
2import { query } from "./_generated/server";
3import schema from "convex/schema.ts";
4
5// This is equivalent to v.object({ name: v.string(), course: v.union(...
6const recipesValidator = schema.tables.recipes.validator;
7// .fields is { name: v.string(), course: v.union(...
8const courseValidator = recipesValidator.fields.course
9
10export const listByCourse = query({
11 args: {
12 course: courseValidator
13 },
14 handler: async (ctx, args) => {
15 return await ctx.db.query("recipes")
16 .withIndex("by_course", (q) => q.eq("course", args.course)
17 .collect();
18 },
19});
20
You can introspect all sorts of values through the validators in a type-safe way.
- A
v.id()
validator has.kind === "id"
and a string.tableName
property of the table name. v.object()
will have.kind === "object"
and have a.fields
property like{ name: v.string() }
v.literal("foo").kind === "literal"
and.value
is"foo"
.v.array(v.null()).kind === "array"
and.element
isv.null()
v.record(v.string(), v.number()).kind === "record"
with.key
and.value
of the key/value validators.v.union(...validators).kind === "union"
with.members
of the validators it's a union of, in the order they were passed in.
But how can you make sure that other parts of your codebase, say, your frontend UI, are using TypeScript types that match those validators?
Add a drop of vanilla TypeScript extract
For exactly that purpose, convex.values
also provides a handy Infer
type that lets you extract TS types from your validators:
1// in convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v, Infer } from "convex/values";
4
5export const courseValidator = v.union(
6 v.literal('appetizer'),
7 v.literal('main'),
8 v.literal('dessert')
9);
10export type Course = Infer<typeof courseValidator>;
11
12// Alternately: Doc<"recipes">["course"]
13
14// ...
15
You can expose the extracted types for use in other parts of your codebase:
1// in src/Menu.tsx
2import { useState } from "react";
3import type { Course } from '../convex/schema.ts';
4
5export default function Menu() {
6 const [course, setCourse] = useState<Course>('main');
7
8 // Then, in response to some user input...
9 setCourse('side dish'); // TS error: invalid Course!
10 // ...
11}
12
Sift out the system fields
The generated Doc
type seen earlier includes the “system fields” automatically added by Convex to every document: _id
and _creationTime
. But often, for example when creating a new document, you want to make sure those fields aren’t included in your data. The "convex/server"
module provides a handy WithoutSystemFields<document>
generic type for just such a situation:
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({ recipeData }:
8 { recipeData: WithoutSystemFields<Doc<"recipes">> }
9) {
10 const createRecipe = useMutation(api.recipes.create);
11 return (
12 <button onClick={() => createRecipe(recipeData)}>
13 Save recipe
14 </button>
15 );
16}
17
But what about the corresponding argument validator? Rather than redefine the same shape of data that you’ve already defined in your schema, you can refactor your schema to export an object with the field validators for a given table, and import that object for use in your functions:
1// in convex/schema.ts
2// ...
3export const recipeFields = {
4 name: v.string(),
5 course: courseValidator,
6 ingredients: v.array(v.string()),
7 steps: v.array(v.string()),
8};
9
10export default defineSchema({
11 recipes: defineTable(recipeFields)
12 .index("by_course", ["course"]),
13});
14
1// in convex/recipes.ts
2// ...
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
No repetition needed, and any changes to the shape of the recipes
table will percolate automatically from schema.ts
. Now we’re cooking!
System fields when using schema introspection
One thing to note: when getting validators like schema.tables.recipes.validator
, that validator does not have system fields on it. So in the above example, you could simply do:
1import schema from "./schema";
2
3export const addRecipe = mutation({
4 args: schema.tables.recipes.validator,
5 handler: async (ctx, args) => {
6 return await ctx.db.insert("recipes", args);
7 },
8});
9
If you want to add system fields, you can do something like this:
1{
2 ...schema.tables.recipes.validator.fields, // note ".fields"
3 _id: Id<"recipes">,
4 _creationTime: number
5}
6
There are a host of helpful validator utilities in convex-helpers/validators
that we'll look at in future posts.
Boiling it all down
To recap, with a little bit of reorganization your Convex codebase can be sweeter than ever, with no repetition or risk of stale data shapes!
- In
schema.ts
, define and export your document fields and their validators separately, then pass them in todefineTable()
- In your Convex functions, validate arguments with the imported validators from your schema instead of repeating yourself.
- Or use the validators through the
schema
introspection. - In your frontend, use Convex-generated types
Doc<table>
andId<table>
, along with type utilities likeInfer<validator>
andWithoutSystemFields<doc>
to convert your schema-defined validators to the TypeScript types you need
Hungry for more tidbits like this to help manage, modify, and manipulate your types and validators? Check out this post for recipes to re-use code for argument validation and schemas.
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.