Structural Typing for Rust
Status: Experimental. API subject to change.
Overview
Structural typing separates schemas (what fields can exist) from selections (what fields must be present). Optionality is context-dependent—a name is always a string, but different contexts may or may not require it. Instead of creating separate types for each context (CreateUser, UpdateUser, DBUser...) or using Option<T> everywhere, define your schema once and use generic trait bounds for context-specific requirements—all verified at compile time.
This design is inspired by Rich Hickey's Maybe Not talk, which articulates the principle of separating attribute definitions from their use in aggregates, along with ideas from TypeScript's structural types and RDF's independent attributes.
Installation
[]
= "0.1.8"
= "1.6" # For deriving traits on structural types
Usage
Define the schema
use structural;
The #[structural] macro generates a module named user containing a Fields trait with associated types for each field. This enables compile-time tracking of which fields are present.
Generic functions with field requirements
Functions can require specific fields through trait bounds using the generated Fields trait. The Presence type can be Present (T), Optional (Option<T>), or Absent (PhantomData<T>):
use Present;
// Requires both id and name
Relaxing requirements is backward compatible. Existing callers with id and name continue to work if we remove the name requirement—unlike changing a function parameter from T to Option<T>, which breaks all existing call sites.
use Access;
// Requires only id; adapts behavior based on whether name is present
Build instances incrementally
The builder API infers field presence from the value type:
let bob = empty.id;
display_user;
let bob = bob.name;
Serde integration
Enable the serde feature to use structural types with serde.
The select! macro creates concrete types, useful for serialization boundaries:
use ;
>
let json = r#"{"name": "Alice", "email": "alice@example.com"}"#;
// name optional, email present, id absent
let req: > = from_str?;
let response = insert_user;
let json = to_string?;
// => {"id":42, "name": "Alice", "email": "alice@example.com"}
Extract and merge
use select;
let = alice.>;
assert_eq!;
assert_eq!;
let alice = credentials.merge;
// Merged values override existing values
let overridden = alice.merge;
assert_eq!;
assert_eq!;
Nested schemas
Schemas can contain other structural types:
// Require specific nested fields
Nested setters
Use #[nested(...)] to generate convenient setters for nested struct fields:
>
// Setters are generated with field_nested_field naming
let user = empty
.id
.name
.address
.address_city
.address_street;
assert_eq!;
assert_eq!;
Nested setters work by extracting the nested field, updating it, and merging back—preserving all other fields' presence states. This makes them compatible with extract() and other structural operations.
Limitations:
#[nested]requires the parent struct to have at least 2 fields because the implementation uses the spread operator, which needs other fields to preserve. Single-field parent structs will produce a compile error.- Merge operations between instances with different nested field presence states are not supported. The nested struct's generic parameter must match for merge to work.
See examples/ for more usage patterns, including a REST API with SQLite demonstrating how one schema handles multiple endpoint types.
License
MIT OR Apache-2.0