Expand description
§bijective
Compile-time verification of surjective, injective, and bijective
properties on enum-to-enum match expressions.
§Background
From a mathematical point of view, match expressions in Rust are total functions by design: The compiler ensures every possible value in the domain (every pattern) is handled.
For most use cases, this is the most that’s required and feasible. However, in
simple enum-to-enum mappings (for example From implementations of enums that
cross architecture boundaries) it is often desireble to have stricter assurances.
For example, when translating between two enum types with a match expression
it is easy to accidentally:
- leave an output variant unreachable (not surjective), or
- map two different inputs to the same output (not injective).
bijective provides add-on attribute macros to enforce compile time checks for
these mistakes, by annotating let _ = match {} bindings or functions returning
a match. Support for attributes on expressions is still unstable (See ) so
only these two use cases are currently provided.
§The three macros
| Attribute | Alias | Property enforced |
|---|---|---|
#[surjective] | #[onto] | Every output variant is produced by at least one arm (onto). |
#[injective] | #[one_to_one] | No two arms produce the same output variant (one-to-one). |
#[bijective] | — | Both of the above simultaneously (bijection). |
§Usage
use bijective::{surjective, injective, bijective};
enum Direction { North, South, East, West }
enum Axis { Vertical, Horizontal }
// OK — every Axis variant is produced at least once.
#[surjective]
fn to_axis(d: Direction) -> Axis {
match d {
Direction::North => Axis::Vertical,
Direction::South => Axis::Vertical,
Direction::East => Axis::Horizontal,
Direction::West => Axis::Horizontal,
}
}
enum Small { A, B }
enum Large { X, Y, Z }
// OK — every Small variant maps to a *distinct* Large variant.
#[injective]
fn embed(s: Small) -> Large {
match s {
Small::A => Large::X,
Small::B => Large::Y,
}
}
enum Letter { A, B, C, D }
enum Number { One, Two, Three, Four }
// OK — every letter maps to a distinct number, and all number variants appear as output.
#[bijective]
fn swap(l: Letter) -> Number {
match l {
Letter::A => Number::One,
Letter::B => Number::Two,
Letter::C => Number::Three,
Letter::D => Number::Four,
}
}§How the checks work
§Surjectivity (#[surjective], #[onto])
The macro generates a private companion function
surjectivity_check_<fn_name> whose body is a match over the output
type covering every unique variant that appears as an arm body. If any
variant of the output enum is absent, the compiler reports a
non-exhaustive pattern error pointing at the #[surjective] attribute.
This type of manual hacks have existed for years in Rust folklore, but it is ugly and verbose, and users first encountering it usually experience a mixture of awe and horror.
The trick is legit though: because the function is dead_code, it has 0
runtime cost, and ensures the verification happens at compile time,
which is usually desirable to tests.
Abstracting away this trick, and replacing it with a single line drop-in
attribute has been the main motivator for this crate. It makes intent clear
and concise in a way a preimage closure with comments just can’t match.
§Injectivity (#[injective], #[one_to_one])
The macro inspects every arm at expansion time and emits a
compile_error! with a span pointing at the duplicate output
variant if the same output path appears more than once.
§Bijectivity (#[bijective])
Combines both checks: the injectivity check runs first (at expansion time), and the surjectivity check is delegated to the compiler via a generated code.
§Constraints and limitations
A mapping function must satisfies all of the following for the attribute to accept it:
- The function body must contain a
matchexpression. If there are several, only the outermost one is analysed. - Every arm pattern must be a plain enum-variant path
(e.g.
Enum::Variant). Wildcards (_), literals, tuple-struct patterns, andor-patterns are not supported. - Every arm body must be a plain enum-variant path
(e.g.
Enum::Variant). Arbitrary expressions, function calls, and struct literals are not supported. - Match guards (
if condition) are not supported. - The checks are purely syntactic: the macro does not resolve types, so it
cannot detect if two syntactically different paths refer to the same variant
via
usealiases. - The surjectivity failure error is not extremely clear. This is due to the way
it is implemented, where the compiler actually sees a missing arm in a
matchexpression of the generated code, so we can’t override that error during macro expansion.
§AI disclosure
LLMs (agents, edit predictions) have been used during the development of this crate’s code. Intent, design and implementation have all been human-driven, and I have read all code and refactored most of it to my own personal liking.
All prose and documentation are my own personal words, and I advocate that others do the same. I’m ok with machines reading machine generated slop, but text made for humans is best written by other humans.
Commits with AI assistance have an Assisted-by footer in the NixOS style
Attribute Macros§
- bijective
- Verifies at compile time that the annotated function’s
matchexpression is bijective: both injective (no duplicate outputs) and surjective (every output variant is covered). - injective
- Verifies at compile time that the annotated function’s
matchexpression is injective (one-to-one): no two arms produce the same output variant. - one_
to_ one - Alias for
#[injective]. - onto
- Alias for
#[surjective]. - surjective
- Verifies at compile time that the annotated function’s
matchexpression is surjective (onto): every variant of the output enum is produced by at least one arm.