Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.