what does rusts type checker knows and does not know
The following erroneous example made me wonder what Rust compiler knows and doesn’t know. Of course, it comes down to the fundamental distinction between compiler-time (static) and runtime (dynamic) information.
let num: i32 = gen_range(0..2);
match num { // num: i32
0 => {},
1 => {},
// Not exhaustive! Compiler doesn't know num is either 0 or 1
// It only knows num has type i32, so it could be any value from -2^31 to 2^31-1
}
Rust requires exhaustiveness based on what it can statically verify.
What Rust’s Type Checker Knows (Compile Time/Static)
-
Types
let x: i32 = some_function(); // what compiler knows: x's type // what compiler does not know: the value of x -
Type bounds and constraints
fn process<T: Display>(item: T) { // What compiler knows: T implements Display // What compiler does not know: the concrete type of T at runtime } -
Struct/enum definitions
enum Animal { Dog, Cat, } // What compiler knows: Animal can only be Dog or Cat // This is encoded in the type system -
Lifetimes
fn foo<'a>(x: &'a str) -> &'a str // What compiler knows: returned reference lives as long as input reference -
Control flow structure
match value { 0 => ..., 1 => ..., // What compiler knows: these are the branches // What compiler does not know: which branch will execute }
What Rust’s Type Checker Doesn’t Know (Runtime/Dynamic)
-
Actual values
let x = rand::thread_rng().get_range(0..2); // What compiler knows: x is i32 // What compiler does not know: x will be 0 or 1 --- runtime fact -
Function return values
fn get_number() -> i32 { ... } let n = get_number()l // What compiler knows: n is i32 // What compiler does not know: the value of n -
Runtime conditions
if some_condition() { // Evaluated at runtime // What compiler does not know: whether this branch runs } -
Contents of collections
let vec = vec![1, 2, 3]; // What compiler knows: vec is Vec<i32> // What compiler does not know: vec contains [1, 2, 3] or even how many elements
*The Key Principle: Type System vs Value Space
Going back to the erroneous example, what is a good way to fix the error?
-
Use a type that encodes the constraint
// Instead of i32 with runtime range [0, 2) enum BinaryChoice { Zero, One } match num { BinaryChoice::Zero => ..., BinaryChoice::One => ... } // Exhaustive!Wait, how do I encode randomness?
Random value -> Type-safe wrapper
// Define the type that encodes the constraint enum BinaryChoice { Zero, One, } impl BinaryChoice { // Generate random instance fn random() -> Self { match rand::thread_rng().gen_range(0..2) { 0 => BinaryChoice::Zero, 1 => BinaryChoice::One, _ => unreachable!(), } } } // Usage - now type-safe! let num = BinaryChoice::random(); match num { BinaryChoice::Zero => Action::Reveal, BinaryChoice::One => Action::Flag, } // Exhaustive!Feels like an overkill…but let’s come back to this option later.
-
Unreachable with wildcard
match get_range(0..2) { 0 => ..., 1 => ..., _ => unreachable!("I know this is - or 1.") } -
Runtime assertion
let num = get_range(0..2); assert!(num == 0 || num == 1); // runtime check match num { 0 => ..., 1 => ..., _ => unreachable!(), }
Among the three options, the most idiomatic and safest may be Option 1: Using a type!
Why may it be most idiomatic?
- “Make invalid states unrepresentable” — core Rust philosophy
- Leverages the type system (Rust’s strength)
- Self-documenting
Action::random()is clear - Compiler-verified exhaustiveness
- Follows Rust patterns like
Option::unwrap(),Result::ok(), etc. - Reusable
Why is it the safest?
- Compile-time safety: compiler enforces exhaustiveness
- No panic possible: no
unreachable!()to accidentally hit - Refactor-safe: if you change to
num = get_range(0..3), compiler forces me to handle it everywhere - future-proof: if
gen_rangebehavior changes, I handle it in one place - No assertion overhead: assertions have runtime cost (even if small)