https://yoric.github.io/post/rust-refinement-types/ Il y a du the renverse au bord de la table ! Articles About RSS rust December 22, 2024 What would it take to add refinement types to Rust? rust compilers type systems mozilla A few years ago, on a whim, I wrote YAIOUOM. YAOIOUM was a static analyzer for Rust that checked that the code was using units of measures correctly, e.g. a distance in meters is not a distance in centimeters, dividing meters by seconds gave you a value in m / s (aka m * s^-1). YAIOUOM was an example of a refinement type system, i.e. a type system that does its work after another type system has already done its work. It was purely static, users could add new units in about one line of code, and it was actually surprisingly easy to write. It also couldn't be written within the Rust type system, in part because I wanted legible error messages, and in part because Rust doesn't offer a very good way to specify that (m / s) * s is actually the same type as m. Sadly, it also worked only on a specific version of Rust Nightly, and the code broke down with every new version of Rust. It's a shame, because I believe that there's lots we could do with refinement types. Simple things such as units of measure, as above, but also, I suspect, we could achieve much better error messages for complex type-level programming, such as what Diesel is doing. It got me to wonder how we could extend Rust in such a way that refinement types could be easily added to the language. Starting with Rust's type system As I've already implemented one refinement type system for Rust, I'll use it as a benchmark of features that would be needed to let us implement this in Rust. Let's assume that we have defined the types Value, Unit, Meter, Second to make the following possible: struct Value where U: Unit { ... }; // a value of type T (e.g. f64) with a unit of type U let a = Value::::new(9.81); // f64 Meter aka Value let b = Value::::new(1.0); // f64 Second aka Value Furthermore, we have defined operations std::ops::Add and std::ops::Sub in such a way that we can write let c = b + b; // f64 Second aka Value let d = b - b; // f64 Second aka Value let _ = a - b; // Error: cannot subtract `Value` from `Value` Multiplication and division are a bit more complicated, because they introduce new units, so let's further assume that we have defined std::ops::Mul, std::ops::Div and two Unit combinators Mul and Div in such a way that we can write let g = a / (b * b); // f64 Meter / (Second * Second) aka Value>>> let h = a / b / b; // f64 Meter / (Second * Second) aka Value, Second>> So far, so good. Unless you want to compare g and h: let diff = g - h; // Error: cannot subtract `Value, Second>>` from `Value>>` which is a shame, because g and h represent the same value and Div , Second>> and Div> represent the same unit of measure. Perhaps we could be smarter about it? In the above encoding, we have used a definition of - with a type signature impl std::ops::Sub for Value where V: std::ops::Sub { type Output = Value<::Output, U>; // ... } i.e. for any number type V and any unit U, - takes two arguments of type Value and produces an argument of type Value. But what we'd like here would be to express for any number type V and any unit types Left and Right, - takes two arguments of type Value and Value and produces an argument of type Value if Left and Right are equivalent. In other words, in terms of type-level programming, what we need here is some form of type-level oracle function Equivalent that is defined if and only if Left and Right are indeed equivalent. Unfortunately, as far as I can tell^1, in Rust, there is no way to define Equivalent to combine the following three properties: 1. Equivalent is defined iff Left and Right are equivalent; 2. keep the system extensible in such a way that other crates can create new units; 3. don't need the user to manually express equivalences between units. So... what if we could expand the Rust type system to help us here? ...then expanding it Our life is complicated because programming with types is complicated. However, if instead of having to implement Equivalent , we needed to implement equivalent(left: UnitRepr, right: UnitRepr), where UnitRepr was an algebraic data structure representing our units of measures, our life would be much easier. If you're curious, Andrew Kennedy formalized the algorithm that does this ~15 years ago, and then implemented it as part of the F# compiler (my own work on YAIOUOM was essentially a port of his work to Rust), so let's take it for granted that such an algorithm exists and that it's possible to implement equivalent or something like it. There are a few places where we could imagine plugging equivalent, or a variant thereof. Option: Trait resolution We could somehow plug into rustc_infer::trait, rustc's implementation of trait resolution, whenever we attempt to resolve Equivalent. This would let us write impl std::ops::Sub> for Value where V: std::ops::Sub, Equivalence: Equivalent { type Output = Value<::Output, C>; // ... } let diff = h - g; // Value where C is the canonicalized version of Div, Second> // and Div> As of rustc 1.85, this might look like an additional variant in SelectionCandidate. I am not convinced by this approach, for several reasons: 1. trait resolution is already quite complex, I'm afraid that this would make it even more complex, with the added risk of slowing down the compiler/never returning; 2. it's not entirely clear to me what should happen in case our plugged-in equivalent returns an error stating that no solution could be found; 3. it's not entirely clear to me what should happen in case our plugged-in equivalent can find more than one equivalence - I suspect that this would probably be caused by an error in the implementation of equivalent, so perhaps this question doesn't need an answer; 4. somehow, this feels too powerful. Option: TypeVar unification We could somehow plug into rustc_infer::infer, rustc's implementation of type inference, wherever we attempt to unify a Unit and a Unit, where either T or U contains a type variable. This would let us keep the definition of subtraction that requires a Unit on both sides. I am also not convinced by this approach, for several reasons: 1. type unification is already non-trivial, as the author of a refinement type system, I don't want to have to reimplement part of unification; 2. type unification happens all over the place, so there is a strong chance that this would slow down type checking considerably; 3. somehow, this feels too powerful. Option: Trait resolution (optimistic) Instead of this, we could offer a lower-powered solution that takes place in two steps. Step 1: During trait resolution, we entirely discard the unit information and assume that Left and Right are equivalent. impl std::ops::Sub> for Value where V: std::ops::Sub, record!(yaoioum, Equivalent) //