The C enum trap
You write enum Status { Ok, Error } in C. You treat it like an integer. You pass it to a function expecting an int. You compare it to 0. It works. You switch to Rust. You write the same code. The compiler rejects you. It says Status is not an integer. You try to cast it. The compiler rejects you again. Rust enums are not integer aliases. They are sum types. If you need C-style behavior, you have to ask for it explicitly.
Rust enums are not integer aliases
In C, an enum is a named set of integers. Red is just 0. Green is 1. The compiler allows you to treat them as integers freely. The type system is loose. Rust takes a different path. A Rust enum is a type that can be one of several variants. The compiler does not guarantee the memory layout. It might pack variants efficiently. It might use a tag byte. It might use nothing if there is only one variant. You cannot cast a Rust enum to an integer by default. The compiler protects you from treating a Color as a u8 unless you prove you know what you are doing.
Think of a C enum as a label on a number. Think of a Rust enum as a box that can hold a Red ball or a Green ball. The box is not a number. The box is a container. If you want the box to be a number, you have to reshape it.
Making Rust enums look like C enums
To replicate C-style behavior, you use the #[repr] attribute. This attribute tells the compiler to use a specific memory representation. You also assign explicit discriminants. The combination forces the enum to behave like a C enum.
#[repr(u8)]
enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
fn main() {
let c = Color::Red;
// Cast to u8. The repr attribute makes this valid.
let value = c as u8;
println!("{}", value); // 0
}
The #[repr(u8)] attribute forces the compiler to store the discriminant as a single byte. The explicit values Red = 0, Green = 1, Blue = 2 match C expectations. Without explicit values, Rust auto-increments, starting from 0. This matches C behavior. You can omit the values and let Rust count up.
#[repr(u8)]
enum Status {
// Ok gets 0 automatically.
Ok,
// Error gets 1 automatically.
Error,
// Timeout gets 2 automatically.
Timeout,
}
The compiler treats this exactly like C. The discriminants are 0, 1, 2. You can cast Status to u8. You can cast u8 to Status. The cast is allowed because the repr matches the target type.
How the compiler lays this out
When you add #[repr(u8)], you are making a contract. The compiler must store the discriminant as a u8. The variants get the values you specify. The memory layout is fixed. The size of the enum is one byte. The alignment is one byte. This matches the C enum layout for small values.
If you use #[repr(C)], the compiler uses the C ABI for enums. The size and alignment match what C expects. This is important for FFI. The C ABI might use int or unsigned int for enums. The size depends on the platform. #[repr(C)] ensures the layout matches C exactly.
#[repr(C)]
enum ErrorCode {
Success = 0,
NotFound = 1,
PermissionDenied = 2,
}
fn main() {
// Size matches C enum size.
let size = std::mem::size_of::<ErrorCode>();
println!("Size: {}", size);
}
The #[repr(C)] attribute is the standard for FFI. It guarantees the ABI matches. For internal use, #[repr(u8)] or #[repr(u32)] is better. It is explicit about the size. It does not depend on the C ABI of the target platform.
Real-world FFI and serialization
The most common use case for C-style enums is FFI. You are calling a C function. The function expects a C enum. Rust must match the layout. You use #[repr(C)]. You define the variants with the same values as C. You call the function. The data passes correctly.
use std::os::raw::c_int;
#[repr(C)]
enum ErrorCode {
Success = 0,
NotFound = 1,
PermissionDenied = 2,
}
extern "C" {
fn get_status() -> ErrorCode;
}
fn main() {
unsafe {
// SAFETY: get_status is assumed to return a valid ErrorCode.
// The repr(C) ensures the layout matches C.
let code = get_status();
match code {
ErrorCode::Success => println!("Good"),
ErrorCode::NotFound => println!("Bad"),
_ => println!("Unknown"),
}
}
}
The #[repr(C)] attribute is required here. Without it, the Rust enum might have a different layout. The C function might read garbage. The program might crash. The unsafe block is required to call the external function. The // SAFETY comment documents the assumption. The function must return a valid discriminant. If it returns an invalid value, the Rust code has undefined behavior.
Another use case is serialization. You are writing a binary protocol. You need fixed-size integers. You use #[repr(u8)]. You serialize the enum as a byte. You deserialize the byte as an enum. The layout is stable. The size is fixed. This works well for network protocols and file formats.
The danger zone: invalid values
Casting an integer to an enum variant is safe syntax. It is not safe behavior. The compiler does not check the value. If you cast an invalid value, you get undefined behavior.
#[repr(u8)]
enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
fn main() {
// This compiles. It runs. It is undefined behavior.
let bad = 99u8 as Color;
// Using bad is UB.
println!("{:?}", bad);
}
The code compiles. The cast 99u8 as Color is allowed. The program runs. When you try to use bad, the behavior is undefined. The program might crash. It might corrupt memory. It might appear to work. The compiler does not protect you here. You must validate the input.
If you forget the cast, the compiler rejects you with E0308 (mismatched types). The compiler sees Color and u8 as completely different types. You must use as to convert. The cast is a bitcast. It reads the bytes. It does not check validity.
Convention: num_enum and derive macros
The community often uses the num_enum crate for safe conversions. The crate generates safe conversion methods. Instead of as, you get try_from. This prevents the undefined behavior of invalid values. It is a community standard for this pattern.
[dependencies]
num_enum = "0.7"
use num_enum::TryFromPrimitive;
#[repr(u8)]
#[derive(TryFromPrimitive)]
enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
fn main() {
// Safe conversion. Returns Result.
let good = Color::try_from(1u8);
println!("{:?}", good); // Ok(Green)
let bad = Color::try_from(99u8);
println!("{:?}", bad); // Error(99)
}
The TryFromPrimitive derive macro generates a try_from method. It checks the value. It returns Ok for valid values. It returns Err for invalid values. This is safer than as. It prevents undefined behavior. The community prefers this for parsing untrusted input.
Another convention is #[derive(Debug)]. Rust enums do not implement Debug by default. You must add the derive. C enums print as integers. Rust enums print as variant names. The Debug implementation prints the variant name. This is useful for logging.
#[repr(u8)]
#[derive(Debug)]
enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
fn main() {
let c = Color::Red;
println!("{:?}", c); // Red
}
The Debug output shows the variant name. It does not show the integer value. If you need the integer value, cast to u8. The community expects Debug to show the logical value, not the memory layout.
Pitfalls and compiler errors
Gaps in values are common in C enums. Rust enums with #[repr] can have gaps too. You can define Red = 0 and Blue = 10. The values 1 through 9 are invalid. Pattern matching handles gaps with _. The _ arm catches invalid values. This is safe. You can log the error. You can return a default. You avoid undefined behavior.
#[repr(u8)]
enum Color {
Red = 0,
Blue = 10,
}
fn main() {
let val = 5u8;
// Cast is allowed.
let c = val as Color;
// Match handles the gap.
match c {
Color::Red => println!("Red"),
Color::Blue => println!("Blue"),
_ => println!("Invalid color: {}", val),
}
}
The _ arm catches the invalid value. The code is safe. You handle the error explicitly. This is the recommended pattern when you must use as. Validate the input before casting, or match on the result.
Traits are another pitfall. Rust enums do not implement Copy or Clone automatically if they have data variants. C-style enums usually have unit variants. Unit variant enums with #[repr] are Copy. This matches C. But you must ensure no data variants exist. If you add a data variant, the enum is no longer Copy. The compiler rejects you with E0277 (trait bound not satisfied) if you try to use Copy.
#[repr(u8)]
enum Status {
Ok,
// Adding data breaks Copy.
Error(String),
}
fn main() {
let s = Status::Ok;
// This fails. Status is not Copy.
let s2 = s;
// s is moved.
println!("{:?}", s); // Error: use of moved value
}
The compiler rejects this with E0382 (use of moved value). The enum is not Copy. You must use Clone or restructure the enum. C-style enums should only have unit variants. If you need data, use a struct or a different pattern.
Decision matrix
Use #[repr(C)] when you are passing the enum across an FFI boundary to C or another language and need the ABI to match exactly. Use #[repr(u8)] or #[repr(u32)] when you need a specific size for serialization or memory constraints, but are not crossing an FFI boundary. Use plain Rust enums without #[repr] when you are writing internal logic and do not care about the integer representation or memory layout. Reach for num_enum when you need safe conversion from integers and want the compiler to check that all values are covered. Reach for pattern matching with _ when you must handle potentially invalid values from external sources.