bijective 0.1.0

Compile-time enforcement of surjective, injective, and bijective properties on enum-to-enum match expressions
Documentation

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/or feasible. However, in simple enum-to-enum mappings (for example, From implementations of enums that cross architecture boundaries) it is often desirable to have stricter assurances.

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 drop-in 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 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 hack has 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 preferable 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 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 generated code.

Constraints and limitations

A mapping function must satisfy all of the following for the attribute to accept it:

  • The function body must contain a match expression. 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, and or-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 use aliases.
  • 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 match expression 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

License

Licensed under the MIT License.