How to Convert Between Enums and Integers in Rust

Convert Rust enums to integers and back using the From and Into traits with match expressions.

When bytes become behavior

You're parsing a binary protocol. The wire format sends a single byte: 0x01 means move forward, 0x02 means stop. Your Rust code has a Command enum. You read the byte from a buffer. Now you need to turn that u8 into Command::Move. You can't just cast it. Rust doesn't trust raw integers to map perfectly to your types. You need a bridge that defines the rules.

In C, you might cast the byte to an enum and hope for the best. If the sensor sends a corrupted 0xFF, your code treats it as a valid command and crashes or does something dangerous. Rust forces you to handle the gap between raw data and typed values. You write the conversion logic, and the compiler enforces that it's complete.

Enums are types, not numbers

Rust treats enums as distinct types. An integer is a number. An enum is a set of named possibilities. Converting between them isn't a mathematical operation. It's a translation. You have to define the mapping. What happens if the integer doesn't match any variant? Rust won't guess. It makes you write the logic. This prevents silent bugs where a corrupted byte turns into a nonsense enum variant.

Think of it like a translator with a strict dictionary. If you say "42" and the dictionary only has entries for "0" and "1", the translator stops. They don't invent a meaning. They flag the input as invalid. Rust forces you to be that translator. You write the dictionary, and the compiler enforces that you handle every case. The dictionary is your match expression. The compiler checks that every key in the input space is covered. If you add a new key to the enum, the compiler checks that the dictionary has an entry for it.

The From and Into traits

The standard way to convert types is the From and Into traits. These traits tell the compiler how to transform one type into another. You implement From<T> to convert from T to your type. You get Into<U> for free.

#[derive(Debug)]
#[repr(u8)]
enum Status {
    Ok = 0,
    Error = 1,
}

// Implement From<u8> to turn integers into the enum.
impl From<u8> for Status {
    fn from(value: u8) -> Self {
        match value {
            0 => Status::Ok,
            1 => Status::Error,
            // Handle invalid values explicitly.
            _ => panic!("Unknown status code: {}", value),
        }
    }
}

// Implement From<Status> to turn the enum back into an integer.
impl From<Status> for u8 {
    fn from(status: Status) -> Self {
        match status {
            Status::Ok => 0,
            Status::Error => 1,
        }
    }
}

fn main() {
    // .into() uses the From implementation automatically.
    let status: Status = 0u8.into();
    let code: u8 = status.into();
}

Implement From, not Into. The standard library provides a blanket implementation: if you implement From<T> for U, you automatically get Into<U> for T. You write the conversion logic once in From, and .into() works in both directions. This is the "From/Into dance." Always implement From.

Convention aside: use #[repr(u8)] when your enum maps to integers. This attribute guarantees the enum uses the same memory layout as a u8. Without it, the compiler chooses the size, which might be larger than one byte. If you're reading bytes from a file or network, #[repr(u8)] ensures the discriminants match the wire format.

Implement From, get Into for free. Do the dance once.

How the compiler resolves conversions

When you call 0u8.into(), the compiler looks for a type that implements Into<Status> for u8. It finds the blanket impl that delegates to From<u8> for Status. Your match runs. It hits 0, returns Status::Ok.

The compiler also checks exhaustiveness. If you add Warning = 2 to Status but forget to update the From<u8> implementation, the compiler rejects you with E0004 (non-exhaustive patterns). This is a feature. It keeps your conversion logic in sync with your data definition. You can't accidentally create a variant that has no integer representation. The compiler forces you to decide how the new variant maps to an integer, or if it should be excluded.

Trust the exhaustiveness check. It catches drift between your enum and your parser.

Handling invalid input with TryFrom

Panicking in From works when you control the input. When you parse data from a network, a file, or user input, the data might be corrupted. You need a way to report errors instead of crashing. That's what TryFrom is for.

TryFrom is the fallible version of From. It returns a Result. You implement TryFrom<T> to convert from T when the conversion might fail. You get TryInto<U> for free.

use std::convert::TryFrom;

#[derive(Debug)]
enum Opcode {
    Add = 0x01,
    Sub = 0x02,
    Mul = 0x03,
}

// TryFrom allows returning an error instead of panicking.
impl TryFrom<u8> for Opcode {
    type Error = String;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            0x01 => Ok(Opcode::Add),
            0x02 => Ok(Opcode::Sub),
            0x03 => Ok(Opcode::Mul),
            // Return an error for unknown values.
            _ => Err(format!("Unknown opcode: 0x{:02x}", value)),
        }
    }
}

impl From<Opcode> for u8 {
    fn from(opcode: Opcode) -> Self {
        match opcode {
            Opcode::Add => 0x01,
            Opcode::Sub => 0x02,
            Opcode::Mul => 0x03,
        }
    }
}

fn main() {
    // try_into() uses the TryFrom implementation.
    let op: Result<Opcode, _> = 0x02.try_into();
    match op {
        Ok(opcode) => println!("Got opcode: {:?}", opcode),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Use try_into() to call the conversion. The compiler generates TryInto from TryFrom just like it does for Into. The type Error associated type lets you define what kind of error the conversion produces. Use a custom error type in real code, not String.

Trust TryFrom for external data. Panics in production are for debugging, not handling.

Pitfalls and anti-patterns

The as cast is tempting. You can write let code: u8 = status as u8; if the enum has discriminants. This compiles. It also hides bugs. If you change the discriminant of Status::Ok to 10, the cast still compiles but produces 10. The compiler doesn't check the value. It just truncates the bits. Never use as for enum-to-int conversion in application logic. Use From or TryFrom. The compiler will force you to update the conversion when the enum changes.

Another trap is assuming From implies a one-to-one mapping. From is infallible. If there's any chance the integer doesn't map to a variant, From is the wrong trait. Using From with a panic! in the catch-all arm is a design choice. It says "this should never happen." If it can happen, use TryFrom.

If you forget to implement the conversion, the compiler rejects you with E0277 (trait bound not satisfied). You'll see a message like "the trait From<u8> is not implemented for Status". This is the compiler telling you the bridge doesn't exist. Build it.

Ditch the as cast. It hides bugs that the compiler would otherwise catch.

Decision matrix

Use From<T> when the conversion is infallible and you control both types. Use TryFrom<T> when the input might be invalid, like data from a network or user input. Use as casts only inside From implementations to extract discriminants, never in application logic. Reach for #[repr(u8)] when you need to guarantee the memory layout matches the integer size, especially for FFI or binary protocols. Pick From over Into when implementing conversions; the compiler generates Into automatically.

Pick the trait that matches the risk. Infallible gets From. Fallible gets TryFrom.

Where to go next