Macros
Use ConvexValue for Rust types you write yourself—application error payloads, patch objects, or
v.any() results you decode by hand. Generated models already derive it; you only need the macro
when you add your own structs or enums.
Convert between your types and SDK values with the runtime codec:
use convex::Value;
use convex_typegen_runtime::{ConvexValue, value_serde::{from_value, to_value}};
#[derive(ConvexValue)]
struct Patch {
title: String,
}
let wire: Value = to_value(&Patch { title: "Draft".into() })?;
let patch: Patch = from_value(wire)?;
The derive implements Serde with Convex wire rules. Pair it with to_value / from_value rather
than serde_json when talking to convex::Value.
Include generated bindings
Use the runtime’s include!() macro to load generated Rust in a module after
Cargo runs the build script. See include bindings from Cargo output
for the default and custom filename forms.
Derive a Convex model
Use ConvexValue when you control a wire shape outside the generated validators,
for example an application error payload, an adapter around v.any(), or a patch
model. For an arbitrary result represented by RawValue, pass its inner SDK value
(raw.0) to from_value::<YourModel>(raw.0) once you know its shape.
The example below encodes a patch into an SDK Value, checks the exact wire keys and
values, and decodes it back. Use the tutorial manifest,
save the file as src/bin/wire.rs, and run cargo run --bin wire. No backend is needed;
success means all assertions pass. The runtime supplies the derive and the codec, so
this example needs no direct Serde dependency.
use std::collections::BTreeMap;
use convex::Value;
use convex_typegen_runtime::ConvexValue;
use convex_typegen_runtime::value_serde::{from_value, to_value};
#[derive(Debug, PartialEq, ConvexValue)]
enum Priority
{
#[convex(literal = "low")]
Low,
#[convex(literal = "high")]
High,
}
#[derive(Debug, PartialEq, ConvexValue)]
struct TaskPatch
{
#[convex(rename = "taskText")]
text: String,
#[convex(optional)]
priority: Option<Option<Priority>>,
}
fn main() -> Result<(), Box<dyn std::error::Error>>
{
for (priority, wire_priority) in [
(None, None),
(Some(None), Some(Value::Null)),
(Some(Some(Priority::High)), Some(Value::String("high".into()))),
] {
let patch = TaskPatch {
text: "Write Rust".into(),
priority,
};
let mut expected = BTreeMap::from([("taskText".into(), Value::String("Write Rust".into()))]);
if let Some(value) = wire_priority {
expected.insert("priority".into(), value);
}
// Encode an SDK value, then decode it back into the handwritten model.
let wire = to_value(&patch)?;
assert_eq!(wire, Value::Object(expected));
assert_eq!(from_value::<TaskPatch>(wire)?, patch);
}
Ok(())
}
Supported attributes and shapes
- Named-field structs encode as objects and reject unknown fields on decode.
Field names default to the Rust spelling, with the
r#prefix removed. - Use
#[convex(optional)]onOption<T>to omitNoneand decode a present key asT. An unmarkedOption<T>is required and nullable. An optional field can also accept null when its inner type accepts null, such as the innerOption<Priority>above. - Use
i64for Int64,f64for Float64,Bytesfor bytes, andBTreeMap<K, V>with string-shaped keys for records. - Use a unit struct with
#[convex(literal = "created")]for an exact wire literal. Booleans, signed 64-bit integers, and floats also work. Write1or1_i64for Int64 and1.0for Float64. The generator can use#[convex(float_bits = ...)]to preserve an exact Float64 bit pattern. - For a string literal enum, annotate each unit variant with a distinct
#[convex(literal = "...")]string. The enum encodes as that exact string. - Tuple variants encode the payload of a single-field tuple variant directly. Decoding
tries variants in declaration order. For a discriminated union, set
#[convex(discriminator = "kind")]on the enum and#[convex(tag = CreatedTag)]on each variant, using its distinct literal type for the requiredkindfield. The decoder selects the branch by that wire field and validates the whole payload.
The derive supports concrete types. Tuple structs, Rust unions, and variants
with unsupported field shapes produce compile-time diagnostics. For generated
function calls, use the typed references under bindings::api.
Why generation stays in the build script
The derive sees only the Rust item it annotates. The generator reads TypeScript or a committed
function spec offline. Keeping those inputs in build.rs lets Cargo rebuild when they change and
gives rust-analyzer a generated Rust file to navigate. Neither macro runs generation or touches the
network.