typex
typex is a lightweight Rust library for inspecting, traversing, comparing and
mutating values at runtime without knowing their concrete types. It supports
common Rust types, such as u8 and Vec<T>, as well as custom structs and
enums using #[derive(Meta)]. Values can be wrapped in an Object, which
provides reflection operations. typex supports no_std.
Why typex?
typex builds on dyn Any and provides an ergonomic API on top of it.
bevy_reflect is a broader reflection
framework used by Bevy. It covers structural type information, dynamic
reflected values, instantiation and serialisation. typex has a smaller surface
and focuses on structural access, mutation and equality of runtime objects.
Instantiation, deserialisation and instance-independent type descriptors are
out of scope.
Contents
- Why typex?
- Features
- Installation
- Supported types
- Quick start
- Core concepts
- Derive macros
- Mutation
- Reflective patching
- Staged mutation batches
- Thread-safe objects
- Hand-written implementations
- Workspace structure
- Licence
Features
- Runtime type introspection of objects
- Structural access, traversal, mutation and equality (fields, keys, items)
- Typed paths for nested access
- Patch operations with batching and rollback
- Type-keyed maps for registries and dispatch tables
- Support for common scalar, container and collection types
- Derive macros for structs and enums
- Low overhead, performance tracked in CI through instruction-count benchmarks
- MSRV: Rust 1.85 (edition 2024)
- Compatible with
no_std+alloc - No runtime dependencies
- No
unsafecode - Small implementation: ~4.7k lines of Rust code
Installation
[]
= { = "0.1.0", = ["derive"] }
The derive feature re-exports the Meta and MetaMut derive macros from
typex_derive. Leave it out to use the reflection traits without the
derives.
The std feature is enabled by default. It adds HashMap support and
Error implementations
for the crate's error types. Disable default features to use typex in
no_std environments with alloc:
[]
= { = "0.1.0", = false }
You can read the full API docs on docs.rs.
Supported types
- Scalars:
bool,char,i8/i16/i32/i64/i128/isize,u8/u16/u32/u64/u128/usize,f32/f64,&'static str,String - Containers:
Option,Result, tuples (up to 8 elements), arrays of any length,Box,Rc,Arc - Collections:
Vec,VecDeque,LinkedList,BTreeSet,BinaryHeap,BTreeMap,HashMap(requiresstd) - Custom structs and enums via
#[derive(Meta)]. Mutable reflection is opt-in via the separateMetaMutderive.
Quick start
Enable the derive feature when using the derive macros.
let object = new;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
let scope_name = ;
assert_eq!;
// Use typed paths to traverse fields without an explicit downcast
assert_eq!;
assert_eq!;
Core concepts
Borrowed object views
Object owns a boxed value with reflective access. ObjectRef is the
corresponding read-only view, which borrows the value without allocating. Use it
when the value already exists elsewhere, or when passing a borrowed view of the
value to another function:
use ObjectRef;
let values = vec!;
let view = new;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
You do not need to construct an ObjectRef explicitly when starting with an
owned Object. The read-only navigation methods on Object return borrowed
ObjectRef values:
let object = new;
let item = object.item.unwrap;
assert_eq!;
ObjectRef is returned by field(), item(), key() and raw path traversal.
Use ObjectRefMut when the underlying value must be changed.
Container access
Objects expose indexed, named and keyed access through item(), field() and
key():
let option = new;
let list = new;
let tuple = new;
let map = new;
assert_eq!;
assert_eq!;
// Tuple fields have no names and are reached by index.
assert_eq!;
assert_eq!;
Map access
key() looks up string-like keys. key_typed() looks up keys using their
concrete key type. visit_map_entries() visits arbitrary keys through a
MapEntryVisitor callback without converting them to strings.
len() reports the number of exposed structural items, including keyed entries
for maps. Hand-written maps implement MapAccess. Maps with non-string keys
must also override Meta::eq_dyn.
use ;
let numbers = from;
assert_eq!;
let mut mutable_numbers = numbers.clone;
mutable_numbers
.key_typed_mut
.unwrap
.set
.unwrap;
assert_eq!;
let object = new;
let mut seen = Vecnew;
assert!;
assert_eq!;
Type shapes
kind() reports the structural shape of an object. access_kind() reports how
the value can be traversed.
let scalar = new;
let shape = ;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
AccessKind::KeyedItem describes sequences that support key() and item()
together, such as a list of records indexed by a unique ID.
Option<T> always reports ValueKind::Option as its own shape. For
Some, field() and key() forward to the inner value, while item(0) and
option_value() expose that value as an ObjectRef. access_kind() follows
the inner value's access mode. For None, these operations return no inner
value or forwarded access.
Enums
Enums report ValueKind::Enum. variant_name() returns the active variant,
whose fields are exposed like those of a struct or tuple: field() and
field_names() for named fields, item() and len() for tuple variants.
access_kind() follows the active variant. A typed path names the required
active variant with PathSegment::Variant(...); subsequent segments access
the variant's fields. variant(name) selects the enum when its active variant
matches name; variant_mut(name) provides mutable access. Both forward
through options. Result follows the same rules, with its payload at item 0.
let status = new;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
let count = ;
assert_eq!;
assert_eq!;
assert!;
Type information
is() checks the concrete type. type_name() and type_id() return its Rust
name and type ID, while type_info() returns a TypeInfo with name() and
id() accessors. Owned values should be created explicitly with Object::new or
ObjectMut::from_clone when an owned mutable clone is required. An ObjectMut
keeps the value mutable while an Object is immutable:
use ObjectMut;
let object = new;
assert!;
let info = object.type_info;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
let mutable = from_clone;
assert_eq!;
Object conversions
ObjectMut::into_object() and SendObject::into_object() convert into a
read-only Object and preserve the value and its concrete type.
ObjectOps provides conversions to borrowed or owned values. to_ref()
borrows the value. to() consumes an owned value and performs an exact-type
downcast without requiring Clone. Use Rust's From or TryFrom traits for
conversions between known concrete types.
assert_eq!;
assert_eq!;
Equality
Meta::eq_dyn() compares two Meta values and is also available as == on
Object, ObjectRef and ObjectRefMut. Mismatched concrete types are
never equal. Structural values compare recursively through exposed fields, map
entries or indexed items. Derived types can opt into their own PartialEq
implementation with #[typex(partial_eq)]; see
Equality options.
Hand-written Meta implementations can implement eq_dyn() using PartialEq,
as #[typex(partial_eq)] does:
use Reflect;
;
let a = ManualId;
let b = ManualId;
assert!;
Equality dispatches on the shape from Meta::reflect(), so a struct or map with
zero exposed fields compares equal to itself:
;
let a = EmptyState;
let b = EmptyState;
assert!;
Paths
ObjectRef::field_path and ObjectRefMut::field_path_mut traverse several hops
in one call using raw paths (&[PathSegment]) or typed paths. Typed paths start
from the FIELD_* constants that #[derive(Meta)] generates. .then(field)
appends a nested field, .item(index) an index into a sequence and .key(key)
a key into a map with string keys.
Raw paths return an ObjectRef to downcast explicitly, while typed paths
return a reference to the target:
use BTreeMap;
let mut payload = Payload ;
let root = new;
assert_eq!;
assert_eq!;
assert_eq!;
Dispatch tables
TypeMap<T> maps type identities to values of type T. It is useful for
registries and dispatch tables. For example, a router could be represented as
follows:
;
;
let mut routes = new;
routes.;
routes.;
assert_eq!;
assert!;
Similarly, a dispatch table can bind marker types to handlers:
;
;
let mut handlers = new;
handlers.;
handlers.;
let handler = handlers..unwrap;
assert_eq!;
Derive macros
With the derive feature, typex re-exports these macros from typex_derive:
#[derive(Meta)]: provides structural read-only access for structs and enums. Add#[typex(opaque)]to hide fields and items.#[derive(MetaMut)]: adds structural mutation and whole-value replacement;Metamust be derived separately.
Opaque types
By default, derived structs expose their fields and enums expose their active
variant and payload fields. Add #[typex(opaque)] when the representation
should remain hidden or child types do not implement the required reflection
trait:
;
For enums, opaque hides both the active variant and its payload fields:
Opaque values still support replacement through
ObjectRefMut::set; see Mutation.
Equality options
#[typex(opaque)] and #[typex(partial_eq)] control different aspects of a
derived type:
#[typex(opaque)]hides fields and items. Withoutpartial_eq,eq_dyn()has no fields or items to compare and returnsfalse, even for the same value.#[typex(partial_eq)]uses the type'sPartialEqimplementation instead of structural equality. It can be used with or withoutopaque.#[typex(opaque, partial_eq)]hides the structure and usesPartialEq.
An opaque type without partial_eq is unequal even to itself:
;
let id = OpaqueId;
assert!;
Combining opaque with partial_eq restores equality via PartialEq:
;
let id = UserId;
assert!;
Capabilities and bounds
Field types must implement Meta. The crate provides implementations for the
supported types.
For generic types, the derives preserve the declared generics. They add a
Meta or MetaMut bound for each field type that uses a type parameter.
Bounding a field type that contains the derived type would overflow trait
resolution. For such a field, the derives bound only the parts that use a type
parameter without the derived type. Here, Tree<T> requires T: Meta for
value, but no bound on Vec<Tree<T>>:
let tree = Tree ;
assert_eq!;
Opaque types expose no child paths, so they add no child capability bounds.
Meta and MetaMut do not require Clone or Debug for ordinary reflective
access. Use ObjectMut::from_clone when an owned clone is needed. Wrapper
Debug implementations report type metadata and shape, so they do not require
the underlying value to implement Debug.
Mutation
ObjectRefMut::set replaces the whole value with an owned Object when
the concrete types match. This also works for opaque values. ObjectRefMut also
provides structural mutation through fields, items and keys:
;
let mut id = UserId;
new
.set
.unwrap;
assert_eq!;
*new
.field_mut
.unwrap
.
.unwrap = 4;
assert_eq!;
The derive macro defines constants for type-safe access. Compose them with
then for nested fields, item for sequence items and key for map entries:
let mut root = new;
*root
.field_path_mut
.unwrap = "secondary";
root
.set_field_path
.unwrap;
drop;
assert_eq!;
assert_eq!;
ObjectRefMut also provides collection operations (item_mut,
key_mut, push_item, insert_key). Each returns a mutable view that supports
set to replace the value or to_mut to access the underlying value.
use BTreeMap;
use ReflectiveError;
let mut values = vec!;
let mut values_ref = new;
values_ref
.item_mut
.unwrap
.set
.unwrap;
*values_ref
.push_item
.unwrap
.
.unwrap += 1;
drop;
assert_eq!;
let mut map = from;
let mut map_ref = new;
map_ref.key_mut.unwrap.set.unwrap;
map_ref.insert_key.unwrap;
drop;
assert_eq!;
assert!;
Reflective patching
A patch groups mutating operations that target one or more paths and applies them in order. Use it to batch fine-grained updates.
use BTreeMap;
use PatchOperation;
let mut config = ServiceConfig ;
let patch = vec!;
new.apply.unwrap;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
let path_patch = vec!;
new.apply.unwrap;
assert!;
assert_eq!;
PatchOperation also provides remove_key, insert_item, push_item,
remove_item and move_item. ObjectRefMut::apply applies operations in
order, stops at the first failure and leaves earlier successful operations
applied. Use a MutationBatch when earlier changes must be undone after a
later operation fails.
SequenceAccessMut::item_mut defaults to None. Custom sequences can support
push and removal while restricting mutable access to existing items.
SequenceAccessMut::move_item defaults to MoveItemError::Unsupported.
Sequential access implementations override it with a native move operation.
Staged mutation batches
Use a MutationBatch when a group of changes must be committed
transactionally. A batch owns its operations without borrowing or changing the
target. commit consumes the batch, applies operations in order against the
target's current state and records owned inverses for successful operations. If
a later operation fails, it replays those inverses in reverse order to roll
back the earlier changes.
use ;
let mut values = vec!;
let batch = from;
let rollback = batch.commit.unwrap;
assert_eq!;
rollback.undo.unwrap;
assert_eq!;
Call MutationRollback::undo immediately or retain the rollback to undo the
changes later. A later batch can then stage replacement and structural
operations in its execution order:
use ;
let mut settings = Settings ;
let name_path = ;
let batch = from;
batch
.commit
.unwrap
.undo
.unwrap;
assert_eq!;
let batch = from;
let rollback = batch.commit.unwrap;
assert_eq!;
rollback.undo.unwrap;
assert_eq!;
Structural operations affect the paths and indexes used by later operations.
Rollback replays an inverse for each operation instead of restoring a
whole-value snapshot, and staged values become visible only after commit.
Custom MetaMut implementations must implement MetaMut::replace_dyn correctly
when whole-value inverse operations are required.
A custom MetaMut implementation can also make a commit fail in a way that
leaves the target inconsistent. CommitError::RollbackFailed reports an
inverse that could not be applied; CommitOperationError::ItemLost reports a
move that could not restore its item. In both cases, treat the target as
invalid for transactional purposes.
Thread-safe objects
Object is not Send. Use SendObject to move a boxed object across thread
boundaries. SendMeta is the backing trait for SendObject; normal callers
should use the owned wrapper:
;
let job = new;
spawn
.join
.unwrap;
Hand-written implementations
Meta::reflect() returns the value's structural shape as a Reflect. Each
structural shape has an access trait: StructAccess, TupleAccess,
EnumAccess, SequenceAccess, KeyedSequenceAccess or MapAccess. An enum
implements EnumAccess, which returns the active variant's name and fields,
and StructAccess or TupleAccess for the variants with named or positional
fields.
MetaMut::reflect_mut() does the same for mutation through StructAccessMut,
TupleAccessMut, EnumAccessMut, SequenceAccessMut,
KeyedSequenceAccessMut, MapAccessMut and OptionAccessMut.
Callers do not import the access traits. Object, ObjectRef, ObjectRefMut
and the trait objects dispatch to them. Importing Meta or MetaMut for the
derives therefore brings only type_info, reflect, reflect_mut, methods
ending in _dyn and the Any conversions into scope, and a call such as
part.len() on a &&str still reaches str::len.
use ;
let ring = Ring ;
let view = new;
assert_eq!;
assert_eq!;
assert_eq!;
Workspace structure
The workspace contains two crates:
typex: runtime reflection, mutation, object APIstypex_derive: derive macros forMetaandMetaMut, re-exported by thederivefeature oftypex
Licence
Licensed under the Apache License, Version 2.0.