Types and Validators in TypeScript: 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
:
// convex/schema.ts
import { v } from "convex/values";
import { defineSchema, defineTable } from "convex/server";
export default defineSchema({
recipes: defineTable({
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())
}).index("by_course", ["course"]),
});
Your function to add a new recipe argument validators might look like:
// in convex/recipes.ts
import { v } from "convex/values";
import { mutation } from "./_generated/server";
export const addRecipe = mutation({
args: {
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()),
},
handler: async (ctx, args) => {
return await ctx.db.insert("recipes", args);
},
});
And for a regular TypeScript function, you might find yourself defining types like:
type Course = 'appetizer' | 'main' | 'dessert';
type Recipe = {
name: string,
course: Course,
ingredients: string[],
steps: string[],
};
async function getIngredientsForCourse(recipes: Recipe[], course: Course) {
...
}
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:
// in src/Cookbook.tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import type { Doc, Id } from "../convex/_generated/dataModel";
export function Cookbook() {
const recipes = useQuery(api.recipes.list);
return recipes?.map((r) => <RecipePreview recipe={r} />);
}
export function RecipePreview({ recipe }: { recipe: Doc<"recipes"> }) {
return (
<div>
{recipe.name} ({recipe.course})
</div>
);
}
function RecipeDetails({ id }: { id: Id<"recipes"> }) {
const recipe = useQuery(api.recipes.getById, { id });
return (recipe && (
<div>
<h1>{recipe.name}</h1>
<h2>{recipe.course}</h2>
<ShoppingList ingredients={recipe.ingredients} />
<Instructions steps={recipe.steps} />
</div>
));
}
This Id<"tablename">
type corresponds to values accepted by v.id("tablename")
:
// in convex/recipes.ts
import { v } from "convex/values";
import { query } from "./_generated/server";
export const getById = query({
args: {
id: v.id("recipes"),
},
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
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:
// in convex/recipes.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByCourse = query({
args: {
course: v.union(
v.literal("appetizer"),
v.literal("main"),
v.literal("dessert")
),
},
handler: async (ctx, args) => {
return await ctx.db.query("recipes")
.withIndex("by_course", (q) => q.eq("course", args.course))
.collect();
},
});
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:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export const courseValidator = v.union(
v.literal('appetizer'),
v.literal('main'),
v.literal('dessert')
);
export default defineSchema({
recipes: defineTable({
name: v.string(),
course: courseValidator,
ingredients: v.array(v.string()),
steps: v.array(v.string()),
}).index("by_course", ["course"]),
});
Now you can reuse those validators in your Convex functions as needed:
// in convex/recipes.ts
import { query } from "./_generated/server";
import { courseValidator } from "convex/schema.ts";
export const listByCourse = query({
args: {
course: courseValidator
},
handler: async (ctx, args) => {
return await ctx.db.query("recipes")
.withIndex("by_course", (q) => q.eq("course", args.course)
.collect();
},
});
This keeps data consistent throughout your entire backend.
Pro tip: once you get the hang of this pattern, you might drop the "Validator," just "course" - it's cleaner.
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:
// in convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v, Infer } from "convex/values";
export const courseValidator = v.union(
v.literal('appetizer'),
v.literal('main'),
v.literal('dessert')
);
export type Course = Infer<typeof courseValidator>;
// ...
You can expose the extracted types for use in other parts of your codebase:
// in src/Menu.tsx
import { useState } from "react";
import type { Course } from '../convex/schema.ts';
export default function Menu() {
const [course, setCourse] = useState<Course>('main');
// Then, in response to some user input...
setCourse('side dish'); // TS error: invalid Course!
// ...
}
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:
// 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);
return (
<button onClick={() => createRecipe(recipeData)}>
Save recipe
</button>
);
}
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:
// in convex/schema.ts
// ...
export const recipeFields = {
name: v.string(),
course: courseValidator,
ingredients: v.array(v.string()),
steps: v.array(v.string()),
};
export default defineSchema({
recipes: defineTable(recipeFields)
.index("by_course", ["course"]),
});
// in convex/recipes.ts
// ...
import { recipeFields } from "./schema";
export const addRecipe = mutation({
args: recipeFields,
handler: async (ctx, args) => {
return await ctx.db.insert("recipes", args);
},
});
No repetition needed, and any changes to the shape of the recipes
table will percolate automatically from schema.ts
. Now we’re cooking! By the way, if you like this pattern, you’ll probably like Ian’s Table
utility in the convex-helpers npm package - post on it coming soon.
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
- 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 sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.