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

Step 1: Define Your Type System

Every database has its own idea of what types exist. SQLite has 5 storage classes (NULL, INTEGER, REAL, TEXT, BLOB). Postgres has dozens. SurrealDB has its own set with Things and Geometry types.

The vantage type system gives you two things:

  1. Type markers — so you can tell the difference between “this is an integer” and “this is text” even when both are stored as serde_json::Value under the hood.
  2. Safe extraction — try_get::<i64>() on a text value returns None instead of silently coercing. This prevents the kind of bugs where a string “42” gets treated as a number somewhere downstream.

Why not just use serde_json::Value directly?

You can! And for simple cases it works fine. The problem shows up when values move between contexts. A JSON number 42 could be an integer, a float, or even a boolean (SQLite stores bools as 0/1). Without type markers, you lose that distinction and get silent data corruption.

Setting it up

Use the vantage_type_system! macro. It generates a trait, an enum of variants, and a type-erased AnyType wrapper:

#![allow(unused)]
fn main() {
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &serde_json::Value) -> Option<Self> {
        use serde_json::Value;
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
}

This gives you SqliteType (trait), SqliteTypeVariants (enum), and AnySqliteType (the type-erased wrapper that remembers which variant a value belongs to).

The value_type is whatever your database driver naturally speaks. For SQL databases that’s usually serde_json::Value. SurrealDB uses ciborium::Value (CBOR). You could use any type — even String if your storage is that simple.

Then implement the trait for each Rust type. Here’s bool — SQLite stores it as 0/1:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;  // bool lives in the Integer family

    fn to_json(&self) -> serde_json::Value {
        Value::Number(if *self { 1.into() } else { 0.into() })
    }

    fn from_json(value: serde_json::Value) -> Option<Self> {
        match value {
            Value::Number(n) => n.as_i64().map(|i| i != 0),
            Value::Bool(b) => Some(b),  // accept native bools too
            _ => None,
        }
    }
}
assert_eq!(AnySqliteType::new(true).try_get::<bool>(), Some(true));
}

And here’s how the type safety works in practice:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
let val = AnySqliteType::new(42i64);
assert_eq!(val.try_get::<i64>(), Some(42));    // same variant → works
assert_eq!(val.try_get::<String>(), None);      // Integer ≠ Text → rejected

let val = AnySqliteType::new("hello".to_string());
assert_eq!(val.try_get::<String>(), Some("hello".to_string()));
assert_eq!(val.try_get::<i64>(), None);          // Text ≠ Integer → rejected
}

You also need From conversions so values can be created conveniently:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(i64::from(*self)) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64().map(|i| i != 0) }
}
impl From<i64> for AnySqliteType {
    fn from(value: i64) -> Self { AnySqliteType::new(value) }
}
impl From<&str> for AnySqliteType {
    fn from(value: &str) -> Self { AnySqliteType::new(value.to_string()) }
}
impl From<bool> for AnySqliteType {
    fn from(value: bool) -> Self { AnySqliteType::new(value) }
}
let val: AnySqliteType = 42i64.into();
let val: AnySqliteType = "hello".into();
let val: AnySqliteType = true.into();
assert_eq!(val.try_get::<bool>(), Some(true));
}

Records and struct conversion

A Record<V> is an ordered key-value map (field name → value). It’s how vantage represents a single row of data regardless of the backend. The question is: how do your structs become Records and vice versa?

There are two paths depending on your value_type.

Path A: serde_json::Value (the easy path)

If your type system uses serde_json::Value as the value type (like SQLite does), you get struct conversion for free. Vantage has blanket implementations of IntoRecord and TryFromRecord for any type that implements serde’s Serialize/Deserialize:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use vantage_types::{IntoRecord, Record, TryFromRecord, TryIntoRecord};
#[derive(Serialize, Deserialize)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}

let product = Product { name: "Cupcake".into(), price: 120, is_deleted: false };

// Struct → Record<serde_json::Value> — automatic via serde
let record: Record<serde_json::Value> = product.try_into_record().unwrap();

// Record<serde_json::Value> → Struct — automatic via serde
let restored: Product = Product::from_record(record).unwrap();
}

This works because serde already knows how to turn structs into JSON objects and back. No extra code needed on your part.

Path B: Custom value type (the #[entity] path)

If your type system uses something other than serde_json::Value — like SurrealDB’s ciborium::Value — then serde’s blanket impls don’t apply. You need the #[entity] proc macro to generate the conversion code:

#![allow(unused)]
fn main() {
use vantage_surrealdb::AnySurrealType;
use vantage_types::entity;
#[entity(SurrealType)]
#[derive(Debug, Clone)]
struct Product {
    name: String,
    price: i64,
    is_deleted: bool,
}
}

The #[entity(SurrealType)] macro looks at each field, and generates:

  • IntoRecord<AnySurrealType> — calls AnySurrealType::new(self.name) for each field
  • TryFromRecord<AnySurrealType> — calls record["name"].try_get::<String>() for each field

This is where the type markers from Step 1 become critical. When you read a record back from the database, each value is an AnySurrealType with a variant tag. The try_get::<String>() call checks that the variant is Text before extracting. If someone stored an integer in a field that should be a string, you get an error instead of garbage.

You can even target multiple type systems at once:

#![allow(unused)]
fn main() {
use vantage_csv::AnyCsvType;
use vantage_surrealdb::AnySurrealType;
use vantage_types::entity;
#[entity(SurrealType, CsvType)]
struct Product {
    name: String,
    price: i64,
}
}

This generates conversions for both Record<AnySurrealType> and Record<AnyCsvType>, so the same struct works across different backends.

Testing Record conversions

Test Record<AnySqliteType> in both modes — typed (write path) and untyped (read path).

Typed records simulate what you’d build when inserting data. Values have variant tags, and try_get enforces them:

#![allow(unused)]
fn main() {
use serde_json::Value;
use vantage_types::{Record, vantage_type_system};
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
#[test]
fn test_typed_record() {
    let mut record: Record<AnySqliteType> = Record::new();
    record.insert("name".into(), AnySqliteType::new("Cupcake".to_string()));
    record.insert("price".into(), AnySqliteType::new(120i64));

    assert_eq!(record["name"].try_get::<String>(), Some("Cupcake".to_string()));
    assert_eq!(record["name"].try_get::<i64>(), None);  // Text ≠ Integer → blocked
    assert_eq!(record["price"].try_get::<i64>(), Some(120));
    assert_eq!(record["price"].try_get::<String>(), None);  // Integer ≠ Text → blocked
}
}

Untyped records simulate what comes back from the database. Values have type_variant: None, so try_get is permissive — it just attempts the conversion:

#![allow(unused)]
fn main() {
use serde_json::{Value, json};
use vantage_types::{Record, vantage_type_system};
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for f64 {
    type Target = SqliteTypeRealMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_f64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl AnySqliteType {
    fn untyped(value: Value) -> Self {
        Self { value, type_variant: None }
    }
}
#[test]
fn test_untyped_record() {
    let mut record: Record<AnySqliteType> = Record::new();
    record.insert("name".into(), AnySqliteType::untyped(json!("Cupcake")));
    record.insert("price".into(), AnySqliteType::untyped(json!(120)));

    assert_eq!(record["name"].try_get::<String>(), Some("Cupcake".to_string()));
    assert_eq!(record["name"].try_get::<i64>(), None);  // fails because "Cupcake" isn't a number
    assert_eq!(record["price"].try_get::<i64>(), Some(120));
    assert_eq!(record["price"].try_get::<f64>(), Some(120.0));  // permissive — json 120 can be f64
}
}

The key difference: a typed AnySqliteType::new(42i64) blocks try_get::<f64>() because Integer ≠ Real. An untyped AnySqliteType::untyped(json!(42)) allows it because there’s no variant to check — it just asks “can JSON number 42 be read as f64?”

Also test Option<T> fields, null handling, and missing fields.

TryFrom<AnyType> for common types

You also need TryFrom&lt;AnyType&gt; implementations for scalar types and Records. These are used later by AssociatedExpression::get() in Step 2, but they belong here because they’re part of the type system:

// Scalars — extract single value from single-row results
impl TryFrom<AnySqliteType> for i64 { ... }
impl TryFrom<AnySqliteType> for String { ... }
// etc.

// Records — extract first row from result array
impl TryFrom<AnySqliteType> for Record<AnySqliteType> { ... }
impl TryFrom<AnySqliteType> for Record<serde_json::Value> { ... }

For scalars, if the result is a single-row, single-column array like [{"COUNT(*)": 3}], extract the value automatically. For Records, extract the first row and wrap each field as an untyped AnyType.

Step 1 conclusion

At this point you should have:

  1. Type impls in src/<backend>/types/ — the vantage_type_system! macro call, trait implementations for each Rust type, From conversions on AnyType, variant detection in TypeVariants::from_*(), and TryFrom&lt;AnyType&gt; for scalars and Records.

  2. Tests in tests/<backend>/1_types_round_trip.rs covering:

    • In-memory AnyType round-trips for each supported type
    • Type mismatch rejections (wrong variant → None)
    • Struct ↔ Record conversions (including Option fields and error cases)
    • Values read from the actual database converting correctly

How type markers flow through the system

The AnyType wrapper has an Option<Variant> field — Some(Integer) means “I know this is an integer”, None means “I don’t know, just try whatever conversion you need.”

Writing (you → database): Values created with AnySqliteType::new(42i64) get type_variant: Some(Integer). The bind layer uses the variant to pick the right sqlx bind call — Integer binds as i64, Text as &str, Real as f64. No guessing.

Reading (database → you): Values coming back from the database are created with AnySqliteType::untyped(json_value) which sets type_variant: None. This means try_get::<i64>() won’t be blocked by a variant mismatch — it just attempts the conversion. The type checking happens later when you deserialize into a struct.

#![allow(unused)]
fn main() {
use serde_json::{Value, json};
use vantage_types::vantage_type_system;
vantage_type_system! {
    type_trait: SqliteType,
    method_name: json,
    value_type: serde_json::Value,
    type_variants: [Null, Integer, Text, Real, Numeric, Blob]
}
impl SqliteTypeVariants {
    fn from_json(value: &Value) -> Option<Self> {
        match value {
            Value::Null => Some(Self::Null),
            Value::Bool(_) => Some(Self::Integer),
            Value::Number(n) if n.is_f64() => Some(Self::Real),
            Value::Number(_) => Some(Self::Integer),
            Value::String(_) => Some(Self::Text),
            Value::Array(_) | Value::Object(_) => None,
        }
    }
}
impl SqliteType for i64 {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_i64() }
}
impl SqliteType for f64 {
    type Target = SqliteTypeRealMarker;
    fn to_json(&self) -> Value { Value::from(*self) }
    fn from_json(value: Value) -> Option<Self> { value.as_f64() }
}
impl SqliteType for String {
    type Target = SqliteTypeTextMarker;
    fn to_json(&self) -> Value { Value::String(self.clone()) }
    fn from_json(value: Value) -> Option<Self> { value.as_str().map(str::to_string) }
}
impl SqliteType for bool {
    type Target = SqliteTypeIntegerMarker;
    fn to_json(&self) -> Value { Value::from(i64::from(*self)) }
    fn from_json(value: Value) -> Option<Self> {
        match value {
            Value::Number(n) => n.as_i64().map(|i| i != 0),
            Value::Bool(b) => Some(b),
            _ => None,
        }
    }
}
impl AnySqliteType {
    fn untyped(value: Value) -> Self {
        Self { value, type_variant: None }
    }
}
// Writing — typed, variant enforced
let val = AnySqliteType::new(true);            // type_variant: Some(Integer)
val.try_get::<bool>();   // Some(true) — variant matches
val.try_get::<i64>();    // Some(1) — bool lives in the Integer family
val.try_get::<String>(); // None — Integer ≠ Text, blocked

// Reading — untyped, permissive
let val = AnySqliteType::untyped(json!(1));    // type_variant: None
val.try_get::<i64>();    // Some(1) — no variant check, json 1 parses as i64
val.try_get::<bool>();   // Some(true) — no variant check, json 1 parses as bool (≠0)
val.try_get::<f64>();    // Some(1.0) — no variant check, json 1 parses as f64
val.try_get::<String>(); // None — json 1 can't parse as String
}

Both directions use Record<AnySqliteType>, but the values behave differently:

Writing:  Struct → Record<AnySqliteType> (typed) → bind_sqlite_value() → sqlx
Reading:  sqlx → Record<AnySqliteType> (untyped) → try_get / serde → Struct