Facet
9 min read
Reflection framework for Rust. One #[derive(Facet)] describes a type once, and every capability — serialization, pretty-printing, diffing, CLI parsing, schema generation, GUI inspection — is a separate consumer reading that description.
rust
#[derive(Facet)]
struct Config {
name: String,
port: u16,
#[facet(sensitive)]
api_key: String,
}
Core model
The distinction that explains the whole ecosystem: serde generates code, facet generates data.
A serde derive emits functions. Attributes are consumed at compile time and burned into a control flow graph, so by runtime the type description no longer exists. A facet derive emits a const-evaluated value — a Shape describing the type's kind, layout, fields, documentation, attributes and type-specific operations — which survives into the running program.
Three consequences follow:
• Attributes survive. Error messages can name fields, suggest corrections and carry byte spans, because the metadata is still there when the error is built.
• Shapes are comparable at runtime. The same derive can treat Vec<u8> as bytes and Vec<u32> as integers by comparing shapes, a decision code generation has to fix at compile time.
• Consumers can arrive later. A crate written after your type still works on it, with no recompilation of your code and no derive to add.
The cost is honest and stated in the upstream docs: runtime traversal replaces monomorphised code, so facet trades some throughput for expressiveness, smaller binaries and one derive instead of several.
| Crate | Role |
|---|---|
facet | Umbrella crate: #[derive(Facet)], the Facet trait, public entry points |
facet-core | Defines Shape, the Def tree, type metadata and pointer vocabulary |
facet-reflect | Reads and builds values of arbitrary shapes via Peek and Partial |
facet-macros | The derive macro itself, built on unsynn |
Ecosystem
Roughly forty crates read the same Shape. They are grouped below by capability rather than by name, which makes the pattern visible: each group is further from "serialise a struct" than the last, and none of them required the others to exist.
Data formats
| Crate | Format |
|---|---|
facet-json | JSON, with span-aware diagnostics |
facet-toml | TOML |
facet-yaml | YAML |
facet-msgpack | MessagePack |
facet-postcard | Postcard, compact binary |
facet-csv | CSV rows and records |
facet-asn1 | ASN.1 DER/BER |
facet-xdr | XDR binary |
facet-urlencoded | application/x-www-form-urlencoded form data |
Document / XML family
| Crate | Description |
|---|---|
facet-xml | XML serialization and deserialization |
facet-dom | Tree-based DOM layer shared by HTML and XML support |
facet-svg | Strongly typed SVG documents on top of facet-xml |
facet-atom | Atom Syndication Format (RFC 4287) documents |
Schema & code generation
The shape turns out to be language-neutral; only the emitter is Rust-specific.
| Crate | Emits |
|---|---|
facet-json-schema | JSON Schema documents |
facet-typescript | TypeScript type definitions |
facet-zod | Zod schemas (runtime validation + inferred TS types) |
facet-python | Python type definitions |
Diagnostics & derive plugins
Not serialization at all — these are traversals of the same tree with a different visitor.
| Crate | Description |
|---|---|
facet-pretty | Pretty-prints with structure, color and sensitive-field redaction |
rediff | Structural diffing with path-aware reports; no PartialEq required |
facet-assert | assert_same! — cross-type structural comparison in tests |
facet-default | Derives Default with per-field custom default values |
facet-error | Derives Error implementations from enum variants and doc comments |
facet-validate | Runs validation attributes during deserialization |
Configuration & CLI
| Crate | Description |
|---|---|
figue | Layers CLI args, environment variables, config files and defaults into one typed model |
facet-styx | Serializes and deserializes Styx documents |
styx-cli | Styx validation, formatting, schema generation and language-server tooling |
facet-cargo-toml | Parses Cargo.toml manifests and Cargo.lock into typed models |
figue is the clearest small demonstration of the model: four unrelated input sources are unified because the shape is the join key, and none of them has to agree with any other.
Web, RPC & UI
| Crate | Description |
|---|---|
facet-axum | Facet-backed extractors and responses for axum |
vox | Rust-native RPC with cross-language codegen and multiple transports |
facet-egui | An egui inspector and editor widget for any Facet type |
facet-egui is the sharpest illustration: nobody writes a UI for your struct, the UI is a function of the shape, and it works on types written before the crate existed.
Database
| Crate | Description |
|---|---|
rusqlite-facet | Binds SQLite query parameters and maps rows through reflected structs |
Runtime & incremental computation
| Crate | Description |
|---|---|
picante | Tokio-first incremental query runtime: memoization, automatic dependency tracking, early cutoff, snapshot isolation |
fable | Tiny typed language evaluated over reflected values; resolves field paths against shapes at compile time, then runs bytecode against live references without serialization |
weavy | Lowered-program IR shared by interpreters and copy-and-patch JIT backends |
Building blocks
| Crate | Description |
|---|---|
facet-value | Dynamic values: JSON-like data plus bytes |
facet-solver | Resolves type shapes from field names and constraints |
facet-path | Tracks paths through nested structures |
strid | Strongly typed string identifiers with Facet integration |
Spun-out projects
Grown out of the ecosystem into independent repositories, each with its own documentation:
| Project | Description |
|---|---|
styx | Configuration language with native parsers for Rust, Python, JavaScript/TS and Go |
dibs | Database workflow: tables as Rust structs, generated migrations, queries, deploys |
vix | Demand-graph language — programs describe rather than run; evaluation happens only where demand reaches |
rodin | Solver specification with conflict learning, used by vix |
vix is where the model is taken furthest: there are no statements and no control flow, a build and a type-check and a test are all just demands on one graph, and identity is split into three planes — location (where in the graph), recipe (operation plus input hashes) and content (the hash of the result).
Renamed or no longer listed
Previously documented here, absent from the current ecosystem listing:
| Old | Status |
|---|---|
facet-args | CLI handling now covered by figue |
facet-diff | Now rediff |
facet-html, facet-html-dom | Folded into facet-dom |
facet-kdl | No longer listed (attributes documented below) |
facet-ansi, facet-singularize, facet-tokio-postgres | No longer listed |
Attribute Quick Reference
Container attributes (struct / enum)
| Attribute | Effect |
|---|---|
deny_unknown_fields | Error on unknown fields during deserialization |
default | Use Default for missing fields |
rename_all = "..." | Rename all fields/variants using a case convention |
transparent | Forward ser/de to inner type (newtype pattern) |
opaque | Hide inner structure; use with proxy for ser/de |
skip_all_unless_truthy | Apply skip_unless_truthy to every field |
type_tag = "..." | Add a type identifier for self-describing formats |
crate = path | Custom path to the facet crate (for re-exports) |
Enum representation attributes
| Attribute | Format | Example output |
|---|---|---|
untagged | No discriminator | 42 / "hello" |
tag = "type" | Internal tag as field | {"type":"Req","id":1} |
tag = "t", content = "c" | Adjacent tag+content | {"t":"Text","c":"hello"} |
Field attributes
| Attribute | Effect |
|---|---|
rename = "..." | Rename field in ser/de |
default / default = val / default = fn() | Default when missing |
skip | Skip in both ser and de (requires default) |
skip_serializing | Skip during serialization only |
skip_deserializing | Skip during deserialization (uses default) |
skip_serializing_if = pred | Conditionally skip serialization |
skip_unless_truthy | Skip when value is falsy (see truthiness rules) |
sensitive | Redact in debug/pretty output ([REDACTED]) |
flatten | Merge nested struct fields into parent |
child | Mark as child node (KDL, XML) |
invariants = fn | Validate after deserialization |
proxy = Type | Use proxy type for ser/de conversion |
opaque, proxy = Type | Opaque + proxy for non-Facet types |
Attribute Details
rename_all — Case conventions
"PascalCase" · "camelCase" · "snake_case" · "SCREAMING_SNAKE_CASE" · "kebab-case" · "SCREAMING-KEBAB-CASE"
transparent — Newtype pattern
rust
#[derive(Facet)]
#[facet(transparent)]
struct UserId(u64); // Serialized as just the u64
opaque — Hide inner structure
Fields don't need to implement Facet. Cannot be serialized on its own — pair with proxy:
rust
#[derive(Facet)]
struct Config {
name: String,
#[facet(opaque, proxy = SecretKeyProxy)]
key: SecretKey, // Serialized as hex string via proxy
}
When assert_same! encounters an opaque type, it returns Sameness::Opaque.
skip_unless_truthy — Truthiness rules
| Type | Truthy when |
|---|---|
bool | true |
| numbers | non-zero (floats exclude NaN) |
Vec, String, slices | non-empty |
Option | Some(_) |
| arrays | non-zero length |
default — Field-level options
rust
#[facet(default)] // Uses Default::default()
#[facet(default = 8080)] // Literal value
#[facet(default = default_timeout())] // Function call
invariants — Post-deserialization validation
Called when partial.build() finalizes a value. Returns bool; false fails deserialization.
rust
#[derive(Facet)]
#[facet(invariants = Range::is_valid)]
struct Range {
min: u32,
max: u32,
}
impl Range {
fn is_valid(&self) -> bool { self.min <= self.max }
}
Limitation: Nested structs are not automatically validated — add an invariant to the parent to check nested values explicitly.
proxy — Custom ser/de representation
Required trait implementations:
• TryFrom<ProxyType> for FieldType — deserialization (proxy → actual)
• TryFrom<&FieldType> for ProxyType — serialization (actual → proxy)
Pattern: Delegate to FromStr / Display
rust
#[derive(Facet)]
#[facet(transparent)]
struct ColorProxy(String); // Must be transparent to serialize as plain string
impl TryFrom<ColorProxy> for Color {
type Error = String;
fn try_from(p: ColorProxy) -> Result<Self, Self::Error> { Color::from_str(&p.0) }
}
impl TryFrom<&Color> for ColorProxy {
type Error = std::convert::Infallible;
fn try_from(c: &Color) -> Result<Self, Self::Error> { Ok(ColorProxy(c.to_string())) }
}
#[derive(Facet)]
struct Theme {
#[facet(proxy = ColorProxy)]
foreground: Color,
}
Pattern: Hex integers
rust
#[derive(Facet)]
#[facet(transparent)]
struct HexU64(String);
// TryFrom<HexU64> for u64 — parses "0x..." hex string
// TryFrom<&u64> for HexU64 — formats as "0x{:x}"
Errors & Diagnostics
Facet errors implement miette::Diagnostic — you get source spans, labels, and typo hints. This is a direct dividend of the core model: the attributes and field names are still available at runtime, so the error can name them.
rust
let user: User = facet_json::from_str(input).into_diagnostic()?;
× unknown field `agge`, expected one of: ["name", "age"]
┌─ <stdin>:1:20
│
1 │ { "name": "Ada", "agge": 36 }
│ ──── did you mean `age`?
Tips:
• Add miette = { version = "7", features = ["fancy"] } for color + unicode boxes
• Pass a named reader for file spans
• Snapshot miette::Report::new(err) in tests to lock error text
Dynamic Values (facet_value::Value)
Format-agnostic equivalent to serde_json::Value. Supports: Null, Bool, Number, String, Bytes, Array, Object, DateTime.
Two-phase deserialization
rust
// 1. Deserialize to dynamic Value
let value: Value = facet_json::from_str(json)?;
// 2. Inspect or transform
println!("{:?}", value.as_object().map(|o| o.keys().collect::<Vec<_>>()));
// 3. Convert to concrete type
let config: Config = from_value(&value)?;
Partial tree extraction
rust
let value: Value = facet_json::from_str(json)?;
if let Some(db) = value.as_object().and_then(|o| o.get("database")) {
let db_config: DatabaseConfig = from_value(db)?;
}
assert_same! — Cross-type structural comparison
No PartialEq required. Can compare a Value against a typed struct:
rust
use facet_assert::assert_same;
let user = User { name: "Alice".into(), age: 30 };
let expected: Value = facet_json::from_str(r#"{"name":"Alice","age":30}"#)?;
assert_same!(user, expected); // Different types, same structure
CLI parsing
Outdated: the sections below document facet-args. The current ecosystem lists figue ("layers CLI args, environment variables, config files and defaults into one typed model") in its place, and its API has not been checked against this note.
Turns any Facet struct into a CLI, with auto-generated help, shell completions and rich error diagnostics.
Attributes
rust
#[facet(args::positional)] // Positional argument
#[facet(args::named)] // Named flag (--field-name)
#[facet(args::named, args::short)] // Named + short flag (-f)
#[facet(args::short = 'o')] // Explicit short flag character
#[facet(args::subcommand)] // Subcommand dispatch
Parsing
rust
use facet_args::from_std_args;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Args = from_std_args()?;
Ok(())
}
Help generation
rust
use facet_args::{generate_help, HelpConfig};
let config = HelpConfig {
program_name: Some("mytool".into()),
version: Some("1.0.0".into()),
..Default::default()
};
println!("{}", generate_help::<Args>(&config));
Shell completions
rust
use facet_args::{generate_completions, Shell};
let bash = generate_completions::<Args>(Shell::Bash, "mytool");
let zsh = generate_completions::<Args>(Shell::Zsh, "mytool");
let fish = generate_completions::<Args>(Shell::Fish, "mytool");
Install locations:
• Bash: ~/.local/share/bash-completion/completions/mytool
• Zsh: ~/.zsh/completions/_mytool
• Fish: ~/.config/fish/completions/mytool.fish
Extension Attributes (Format Crates)
KDL
Attributes for KDL documents:
rust
#[facet(kdl::node_name)] // Field holds the KDL node name
#[facet(kdl::argument)] // KDL positional argument
#[facet(kdl::property)] // KDL property
#[facet(kdl::children)] // Collection of child nodes (auto-singularizes field name)
#[facet(kdl::children = "tag")] // Children with explicit node name
Feature flags
Enable in Cargo.toml (see cargo):
toml
[dependencies]
facet = { version = "...", features = ["uuid", "chrono"] }
Third-party crate types
| Feature | Crate | Types |
|---|---|---|
uuid | uuid | Uuid |
ulid | ulid | Ulid |
url | url | Url |
chrono | chrono | DateTime<Tz>, NaiveDate, NaiveTime, NaiveDateTime |
time | time | Date, Time, PrimitiveDateTime, OffsetDateTime, Duration |
jiff02 | jiff | Timestamp, Zoned, DateTime, Date, Time, Span, SignedDuration |
camino | camino | Utf8Path, Utf8PathBuf |
bytes | bytes | Bytes, BytesMut |
ordered-float | ordered-float | OrderedFloat<f32/f64>, NotNan<f32/f64> |
ruint | ruint | Uint<BITS, LIMBS>, Bits<BITS, LIMBS> |
Standard library types
| Feature | Types |
|---|---|
nonzero | NonZero<T> variants (NonZeroU8, NonZeroI32, …) |
net | SocketAddr, IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6 |
tuples-12 | Tuples up to 12 elements (default: 4) |
fn-ptr | Function pointer types |
doc | Include /// doc comments in Shape/Field/Variant (increases binary size) |