Build your first project
Build a task list that writes a row and observes it through a live query. By the end you will have called all three Convex function kinds and handled a typed application error.
This tutorial is the shortest path to end-to-end type safety: after generation, tasks::list
expects ListArgs, returns Vec<String>, and tasks::add returns Id<tables::Tasks>—the
compiler rejects the wrong reference, args, or result type before you deploy. See
what’s guaranteed and what isn’t for compile-time limits and runtime checks.
The generator reads your schema and local TypeScript or JSON and stays offline during builds. It requires Rust 1.97; the runtime supports 1.85.
1. Create the project
Install Rust 1.97 or newer, Node.js 22 or newer with npm, and Docker. Node and Docker are for the backend setup and deployment. Cargo can generate and compile the bindings without either.
cargo new --edition 2024 typed-tasks
cd typed-tasks
mkdir convex
Replace the empty [dependencies] section of Cargo.toml with the two sections below.
The two typegen crates must have matching release versions. These instructions target v1; until v1 is published, use path dependencies
pointing at the repository root and its runtime/ directory.
[dependencies]
convex = "0.10.4"
convex-typegen-runtime = "1"
futures = "0.3"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
[build-dependencies]
convex-typegen = "1"
Copy this package.json to the project root. The Convex CLI ships with the Convex npm package.
{
"name": "convex-typegen-tutorial",
"private": true,
"dependencies": {
"convex": "1.49.0"
},
"devDependencies": {
"typescript": "5.9.3"
}
}
2. Declare the Convex schema and functions
Create convex/schema.ts. The optional priority can be omitted, set to null, or set to one of two
strings. Rust preserves all three cases.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export const priority = v.nullable(v.union(v.literal("low"), v.literal("high")));
export default defineSchema({
tasks: defineTable({ text: v.string(), priority: v.optional(priority) }),
});
Create convex/tasks.ts. Each endpoint declares both validators, even when the argument object
is empty. The Convex CLI generates _generated/server during deployment; Rust generation can
recognize the import without that generated directory.
import { action, mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { priority } from "./schema";
export const list = query({
args: {},
returns: v.array(v.string()),
handler: async (ctx) =>
(await ctx.db.query("tasks").collect()).map((task) => task.text),
});
export const add = mutation({
args: { text: v.string(), priority: v.optional(priority) },
returns: v.id("tasks"),
handler: async (ctx, args) => {
if (!args.text.trim()) {
throw new ConvexError({ code: "empty_text" });
}
return await ctx.db.insert("tasks", args);
},
});
export const ping = action({
args: {},
returns: v.string(),
handler: async () => "pong",
});
To enable TypeScript validation on deployment, also create convex/tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["./**/*.ts"],
"exclude": ["./_generated"]
}
3. Generate and use the bindings
Create build.rs next to Cargo.toml:
fn main()
{
convex_typegen::build::generate_out_dir().expect("generate Convex bindings");
}
The build script reads the local validators and writes convex_api.rs into Cargo’s OUT_DIR.
Add src/main.rs in stages, then use the complete file below.
Connect and list
Create the Convex Rust client, wrap it with
TypedClient, and run the tasks:list
query:
let mut sdk = ConvexClient::new(&std::env::var("CONVEX_URL")?).await?;
let mut client = TypedClient::new(&mut sdk);
let before: Vec<String> = client.query(tasks::list, tasks::ListArgs {}).await?;
Reject bad input and call an action
Use with_error for a typed application error
payload. Send priority: None to omit the optional field on the wire. Run a
mutation and an
action the same way—pass the generated reference and
matching args struct.
Subscribe after a write
Open a subscription on tasks:list, insert a row with high priority
(Some(Some(tasks::AddArgsPriority::High))), and wait until the stream reports the new text. See
typed calls.
Complete main.rs
#[allow(dead_code)]
mod convex_api
{
convex_typegen_runtime::include!();
}
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use convex::ConvexClient;
use convex_api::api::tasks;
use convex_api::tables;
use convex_typegen_runtime::model::Id;
use convex_typegen_runtime::{CallError, ConvexValue, TypedClient};
use futures::StreamExt;
use tokio::time::timeout;
#[derive(Debug, ConvexValue)]
struct AddFailure
{
code: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>>
{
let mut sdk = ConvexClient::new(&std::env::var("CONVEX_URL")?).await?;
let mut client = TypedClient::new(&mut sdk);
let before: Vec<String> = client.query(tasks::list, tasks::ListArgs {}).await?;
println!("Before: {before:?}");
let reply: String = client.action(tasks::ping, tasks::PingArgs {}).await?;
println!("Action: {reply}");
assert_eq!(reply, "pong");
let add = tasks::add.with_error::<AddFailure>();
let invalid = tasks::AddArgs {
text: String::new(),
priority: None,
};
match client.mutation(add, invalid).await {
Err(CallError::Application { data: Ok(failure), .. }) => {
assert_eq!(failure.code, "empty_text");
println!("Rejected: {}", failure.code);
}
Err(other) => return Err(other.into()),
Ok(_) => return Err("the backend accepted empty text".into()),
}
// ANCHOR: subscribe
let mut updates = client.subscribe(tasks::list, tasks::ListArgs {}).await?;
let text = format!("Write Rust ({})", SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos());
let id: Id<tables::Tasks> = client
.mutation(
add,
tasks::AddArgs {
text: text.clone(),
priority: Some(Some(tasks::AddArgsPriority::High)),
},
)
.await?;
println!("Created: {}", id.as_str());
// The stream can first yield the initial result. Wait for this write to appear.
timeout(Duration::from_secs(10), async {
while let Some(update) = updates.next().await {
if update?.contains(&text) {
println!("Observed: {text}");
return Ok::<(), Box<dyn std::error::Error>>(());
}
}
Err("subscription ended before observing the write".into())
})
.await??;
// ANCHOR_END: subscribe
Ok(())
}
Run cargo check now. Cargo may fetch Rust dependencies the first time; generation itself
reads only local files. The function references tell the compiler which kind of call to make
and which arguments and result to expect. For example, tasks::add takes AddArgs and returns
an Id<tables::Tasks>. See generated models
for the three priority shapes.
4. Deploy and run locally
Start the self-hosted Convex backend with Docker. This example uses a disposable container on loopback:
docker run --detach --name typed-tasks-convex \
--publish 127.0.0.1:3210:3210 --publish 127.0.0.1:3211:3211 \
--env CONVEX_CLOUD_ORIGIN=http://127.0.0.1:3210 \
--env CONVEX_SITE_ORIGIN=http://127.0.0.1:3211 \
--env DISABLE_BEACON=true \
ghcr.io/convex/convex-backend@sha256:e9c084e8bd6c1f249012c672193f0988c748824f55a24cdad45f6185a8462f9a
Once curl -f http://127.0.0.1:3210/version succeeds, generate a deployment admin key and deploy
the functions from the project root:
export CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
export CONVEX_SELF_HOSTED_ADMIN_KEY="$(docker exec typed-tasks-convex ./generate_admin_key.sh | tail -n 1)"
npm install
npx convex dev --once --typecheck enable
unset CONVEX_SELF_HOSTED_ADMIN_KEY
CONVEX_URL=http://127.0.0.1:3210 cargo run --bin typed-tasks
On a fresh database, expect output like this (the ID and timestamp will differ):
Before: []
Action: pong
Rejected: empty_text
Created: <task id>
Observed: Write Rust (<timestamp>)
The mutation writes the task, and the subscription delivers a new list containing it. Running
again starts with the previous rows. When finished, remove the disposable container with
docker rm -f typed-tasks-convex. See deploy and connect for environment and
authentication details, or troubleshooting if the output differs.