Trait-Based Domain Constraints in Rust: A Case Study
Sometimes we need to express compatibility constraints between interdependent types. While it could be enforced using runtime assertions, Rust allows us to do much better using generics for compile-time validation.
I encountered this situation while working on the last AudioNimbus release, and it just so happens that it makes for a great follow-up to the typestate builder pattern I presented in a previous post.
The Problem: Enforcing Compatibility Between Types
In AudioNimbus, users can choose which audio simulation algorithms they want to enable.
These simulation types are expressed in the form of capability markers:
/// Marker type indicating that direct sound simulation is enabled (simulation of distance attenuation, occlusion, etc).
struct Direct;
/// Marker type indicating that physics-based reflection simulation is enabled (room acoustics and reverberation).
struct Reflections;
/// Marker type indicating that pathing simulation is enabled (simulation of multiple paths that sound can take as it propagates from the source to the listener).
struct Pathing;
The Simulator responsible for simulation is generic over these simulation types, indicating which features are turned on:
struct Simulator<D = (), R = (), P = ()> {
_direct: PhantomData<D>,
_reflections: PhantomData<R>,
_pathing: PhantomData<P>,
// ...
}
For instance, a Simulator<Direct, Reflections, ()> means both direct and reflections simulations are enabled for this simulator, but not pathing.
This allows us to condition certain methods to the activation of their associated feature. In other words, you can’t call reflections methods when the Reflections marker is absent, thus catching API misuse at compile time (and in the case of AudioNimbus, preventing segfaults since it interfaces with FFI C).
Note that because D, R and P are not used in any of the Simulator fields, we need PhantomData so that the type acts as though it stores a value of D, R and P.
Users don’t need to explicitly set markers. They are automatically attached by constructors using the typestate builder pattern, making their use seamless.
What’s nice is that objects that depend on this simulator can inherit its features using the same markers signature. Now the whole chain of objects strictly follows the same behavior, enforced by generics at compile time, thus preventing misuse of the API and locking everything into place.
This is the case for Sources (which describe where sound originates for the purposes of simulation). It follows the same pattern:
struct Source<D = (), R = (), P = ()> {
_direct: PhantomData<D>,
_reflections: PhantomData<R>,
_pathing: PhantomData<P>,
// ...
}
But here comes a subtle nuance: sources don’t need to strictly follow the simulator’s markers; Sources can enable some or all of the simulator features.
In other words, Source’s generics can be any subset of the simulator’s it is tied to.
For example, with Simulator<Direct, Reflections, ()>, a source can be:
Source<Direct, Reflections, ()>Source<Direct, (), ()>Source<(), Reflections, ()>Source<(), (), ()>
Why does this matter? Imagine a simulator with reflections disabled trying to process a source that expects reflection data - this would cause a runtime crash when calling into the C FFI layer. But the other way around is also legitimate: a simulator with reflections enabled can process a source without reflections.
We need the compiler to prevent any misuse at compile time. But how can we instruct the compiler to enforce these constraints?
Trait-Based Compatibility Constraints
The trick is to add safeguards at the constructor level.
Without the added constraints, a constructor for Source would typically look like:
impl<D, R, P> Source<D, R, P> {
fn new(simulator: &Simulator<D, R, P>) -> Self {
Self {
_direct: PhantomData,
_reflections: PhantomData,
_pathing: PhantomData,
// ...
}
}
}
At this point, Source takes exactly the same D, R and P as the Simulator.
Let’s add ‘compatibility traits’ to loosen it up. We’ll start with the direct simulation marker - others will follow.
trait DirectCompatible<SimulatorD> {}
impl DirectCompatible<Direct> for Direct {}
impl DirectCompatible<Direct> for () {}
impl DirectCompatible<()> for () {}
Let’s unpack it.
DirectCompatible is a trait that is itself generic over any D inherited from the Simulator, that we conveniently call SimulatorD - i.e., the D in Simulator<D, R, P>, which can be Direct, or () depending on whether direct simulation is enabled or not.
The next three impl statements should read as “which types are compatible with a certain simulator D”.
Let’s break it down further:
impl DirectCompatible<Direct> for Direct {}
impl DirectCompatible<Direct> for () {}
These two mean: if Simulator has Direct enabled, our value must be either Direct as well, or (). Meaning in that case, direct simulation can be enabled to match the simulator, or disabled.
impl DirectCompatible<()> for () {} means that if the simulator has direct simulation disabled, objects must have a D that is () in order to be compatible. In other words, if the simulator has direct simulation disabled, tied objects must also have direct simulation disabled.
All other combinations don’t implement DirectCompatible, meaning they are incompatible.
To sum things up, we end up with this compatibility table:
Simulator D | Compatible Source D |
|---|---|
Direct | Direct or () |
() | () only |
After implementing respective traits for Reflections and Pathing in a similar fashion, we can update our constructor:
impl<D, R, P> Source<D, R, P> {
fn new<SimulatorD, SimulatorR, SimulatorP>(
simulator: &Simulator<SimulatorD, SimulatorR, SimulatorP>,
) -> Self
where
D: DirectCompatible<SimulatorD>,
R: ReflectionsCompatible<SimulatorR>,
P: PathingCompatible<SimulatorP>,
{
Self {
_direct: PhantomData,
_reflections: PhantomData,
_pathing: PhantomData,
// ...
}
}
}
What’s changed is that our new constructor is now generic over SimulatorD, SimulatorR, and SimulatorP, which are the generics of the Simulator we take as an argument.
The where clause is where it all comes together.
We specify that the D, R and P generics that Source will be created with, must implement the compatibility traits we implemented. In other words, D (resp. R, P) must be compatible with the SimulatorD of the simulator (resp. SimulatorR, SimulatorP) following the rules we defined earlier.
Now the generics of Source can only be a subset of the generics of its Simulator argument.
Closing Notes
This pattern is yet another demonstration of how Rust’s type system can encode complex domain constraints, moving potential runtime errors into compile-time guarantees.
A nice addition would be to prevent users from implementing the compatibility traits themselves using the sealed trait pattern, which I’ll cover in a future post.
The examples have been voluntarily simplified for the sake of demonstration and clarity. If you are curious, you can see the full implementation in this pull request and follow AudioNimbus updates as I make progress toward stabilizing the API.