Pre-RFC: Compile-time conditional branching on trait implementations
I'd like to propose a way for generic Rust code to conditionally compile branches depending on whether a type implements a trait.
The idea is superficially similar to instanceof in some higher-level languages, but the condition would be resolved entirely at compile time rather than through runtime type inspection.
Motivation
Consider:
trait Describe {
fn describe_static() -> &'static str;
fn describe(&self) -> &'static str;
}
fn do_something<T>(this: &T) {
let static_description = if T: Describe {
T::describe_static()
} else {
"No static description available"
};
let description = if this: &Describe {
this.describe()
} else {
"No description available"
};
}
Here:
T: Describe
and:
this: &Describe
would be compile-time predicates.
A successful predicate would introduce the corresponding information into its branch. Thus T::describe_static() is valid inside the first branch because the compiler knows T: Describe there.
Likewise:
if this: &Describe {
this.describe()
}
establishes the required capability for this inside that branch.
This requires no Any, dyn Trait, RTTI, or runtime trait lookup.
Exhaustiveness check
The same mechanism could allow generic code to support several alternatives and reject unsupported concrete instantiations.
For example:
fn stringify<T>(value: &T) -> String {
if T: Display {
format!("{}", value)
} else if T: Debug {
format!("{:?}", value)
} else {
compile_error!("T must implement Display or Debug");
}
}
For a concrete T, at least one supported branch must apply or compilation of that instantiation fails.
Conceptually, this function accepts:
T: Display OR T: Debug
without introducing another common trait solely for dispatch.
If T implements both traits, ordinary if / else if ordering could establish precedence.
compile_error! is used here only to demonstrate the intended semantics. Whether this should literally use compile_error!, some form of language-level exhaustiveness checking, or another mechanism is open for discussion.
Boolean algebra over trait predicates
Once trait satisfaction is a compile-time Boolean predicate, logical operators become a natural extension.
AND
if T: Read && T: Write {
// T implements both
}
OR
Boolean operators could also apply to predicates on values:
let value: &T = ...;
if value: &Display || value: &Debug {
// value satisfies &Display, &Debug, or both
stringify(value);
}
Inside this branch, the compiler knows:
value: &Display OR value: &Debug
The branch does not need to immediately determine which alternative is satisfied. The Boolean constraint itself becomes part of the branch-local type information.
The corresponding type-level form would be:
if T: Display || T: Debug {
// T implements Display, Debug, or both
}
This differs from:
if T: Display {
// T: Display
} else if T: Debug {
// T: Debug
}
where each branch establishes one specific trait.
Rust already has a straightforward way to express simultaneous trait requirements:
T: Read + Write
but there is no corresponding direct form for an alternative requirement such as:
T: Display OR T: Debug
NOT
if !(T: Clone) {
// T does not implement Clone
}
Predicates could also be composed:
if (T: Read && T: Write) || T: Serialize {
// ...
}
Compile-time instanceof
A useful mental model for this proposal is a compile-time analogue of instanceof.
In a language with runtime type inspection, it is natural to ask a type question about essentially any object:
if value instanceof SomeInterface {
...
}
The object does not need to declare in advance that such a check will be performed. The check itself determines whether the corresponding branch is applicable at runtime.
The proposed Rust construct follows the same mental model:
if value: &Display {
// ...
}
except that the check determines whether the corresponding branch is applicable during monomorphization, when the concrete type is known.
The programmer can therefore ask the question about any generic value:
fn process<T>(value: &T) {
if value: &Display {
// Display-specific code
} else {
// fallback
}
}
process itself does not need:
T: Display
because implementing Display is not a precondition for calling process.
It is simply a compile-time question asked by its implementation.
The mental model is therefore:
runtime instanceof:
object
-> inspect runtime type
-> choose branch
compile-time trait predicate:
generic value
-> concrete type becomes known during monomorphization
-> resolve trait predicate
-> compile the applicable branch
Why monomorphization?
The reason for resolving these predicates during monomorphization is that this is the point where the generic type becomes a concrete type.
Consider:
fn stringify<T>(value: &T) -> String {
if T: Display {
format!("{}", value)
} else if T: Debug {
format!("{:?}", value)
} else {
compile_error!("T must implement Display or Debug");
}
}
The function itself does not need to restrict T. It can be called with any type:
fn foo<T>(value: &T) {
stringify(value);
}
During monomorphization, however, T is concrete. At that point the compiler can answer:
Does T implement Display?
Does T implement Debug?
and select the corresponding branch.
This avoids requiring conditional trait requirements to become part of generic function signatures.
If stringify instead required something like:
T: Display OR T: Debug
then foo might need to repeat that requirement merely because it calls stringify.
A generic caller of foo might then need to propagate it again.
For more complicated conditions:
(A && B) || C
or:
(A || B) && !(C)
local implementation details could propagate throughout the generic call graph as increasingly complicated bounds.
Resolving the condition during monomorphization avoids that propagation. The generic function remains unconstrained, while each concrete instantiation is checked against the branches it actually uses.
For example:
stringify::<A>(...) // A: Display -> compile Display branch
stringify::<B>(...) // B: Debug -> compile Debug branch
stringify::<C>(...) // neither -> compilation error
This is also consistent with the instanceof mental model: the programmer is allowed to ask the question about any value. The difference is when the answer becomes available.
At runtime, instanceof answers the question from the runtime type.
Here, monomorphization answers the question from the concrete compile-time type.
The conditional capability therefore remains an implementation detail of stringify, rather than becoming a requirement that has to be expressed and propagated through every generic caller.
There is no runtime type inspection or runtime dispatch involved.
Extension: compile-time duck typing
(click to expand)
OR predicates could potentially go beyond conditional branching and enable a statically checked form of duck typing.
Consider two unrelated traits:
trait MyTrait1 {
fn same_method_name();
}
trait MyTrait2 {
fn same_method_name();
}
If every alternative admitted by an OR predicate provides the same compatible operation, it could potentially be possible to write:
fn do_something<T>() {
if T: MyTrait1 || T: MyTrait2 {
T::same_method_name();
}
}
The code does not care which trait provides the operation.
It only cares that every possibility admitted by the predicate provides a compatible:
fn same_method_name();
In that sense, the usable interface inside an OR branch could include operations common to all alternatives.
This would provide a form of compile-time, statically checked duck typing.
An ambiguity occurs if T implements both traits.
In that case:
T::same_method_name();
could be ambiguous.
Rust could reject the ambiguous call and require explicit qualification:
<T as MyTrait1>::same_method_name();
or use some other rule.
I think this is something the design discussion should determine rather than something this Pre-RFC needs to prescribe.
Relationship to specialization
This overlaps conceptually with specialization, but the proposed construct would allow specialization locally inside an ordinary generic function:
fn process<T>(value: &T) {
// common logic
if T: SomeOptimization {
// optimized implementation
} else {
// generic implementation
}
// common logic
}
Some such cases can be represented using helper traits and specialized implementations, but that moves a local implementation choice into separate traits and impl blocks.
The proposed mechanism keeps that decision where it is actually used.
Extension: concrete type predicates
(click to expand)
The same concept could potentially be applied to concrete types.
For example:
fn unwrap_str<T>(value: T) {
let q = if value: &str {
*value
} else if value: &&str {
**value
} else {
compile_error!("expected &str or &&str");
};
}
Here &str and &&str are distinct types.
Inside the first branch, the predicate establishes that value has type &str, so:
*value
is valid.
Inside the second branch, it establishes that value has type &&str, so:
**value
is valid.
Again, the question can be asked without restricting T in advance.
For the simplest semantics, concrete type matching would be exact:
value: &str
means exactly &str, not &&str and not another type that can coerce or autoderef to &str.
Whether concrete type predicates belong in the same feature or should be a separate proposal is open for discussion.
Summary
The core primitive is:
if T: Trait {
// T: Trait is known here
} else {
// fallback
}
or, for a value:
if value: &Trait {
// value satisfies &Trait here
} else {
// fallback
}
Boolean algebra could extend the same model:
if T: Read && T: Write {
// ...
}
if value: &Display || value: &Debug {
stringify(value);
}
if !(T: Clone) {
// ...
}
OR could further enable common operations across alternative traits:
if T: MyTrait1 || T: MyTrait2 {
T::same_method_name();
}
and concrete type predicates could potentially extend the same idea beyond traits:
if value: &str {
// ...
} else if value: &&str {
// ...
}
The essential idea is simple:
Ask a type question about any generic value or type, resolve the answer during monomorphization when the concrete type is known, and compile only the applicable branch.
Everything else — exhaustiveness checking, Boolean algebra, duck typing, and concrete type predicates — can potentially build on that primitive.
I'm interested in whether something equivalent has been considered before, what problems this would introduce for Rust's type system and monomorphization model, and whether there is a more Rust-idiomatic way to express the same capability.