C often represents state with integers:
#define STATE_IDLE 0
#define STATE_READING 1
#define STATE_ERROR 2
Or with enum:
enum State {
STATE_IDLE,
STATE_READING,
STATE_ERROR,
};
This is much clearer than bare numbers, but state is often more than a name. Different states may need different data. C code commonly puts a state code and many fields into one struct, then relies on convention to know which fields are valid.
Rust enum is closer to “a type that can be exactly one of several shapes,” and each shape can carry its own data.
C State Structs Allow Invalid Combinations
Consider a simplified C struct:
enum ConnState {
CONN_DISCONNECTED,
CONN_CONNECTING,
CONN_CONNECTED,
CONN_ERROR,
};
struct Connection {
enum ConnState state;
int fd;
int retry_count;
int error_code;
};
All fields are always present, but not every state needs every field.
- Is
fdvalid whenDISCONNECTED? - Is
error_codevalid whenCONNECTING? - Does
retry_countmatter whenCONNECTED? - Should
fdbe closed whenERROR?
These rules are maintained by convention. The struct can represent invalid combinations:
state = DISCONNECTED, fd = 10
state = CONNECTED, error_code = 5
state = ERROR, fd still looks valid
C can reduce this with comments, wrapper functions, and state-machine discipline, but the type itself does not block invalid states.
Rust enum Lets Each State Carry Its Data
Rust can express the state like this:
enum Connection {
Disconnected,
Connecting { retry_count: u8 },
Connected { fd: i32 },
Error { code: i32 },
}
The meaning is direct:
Disconnectedhas no extra dataConnectinghasretry_countConnectedhasfdErrorhascode
This is not just C enum with different syntax. It binds each state to the data valid in that state.
Now “Disconnected but with a valid fd” cannot be constructed, because the Disconnected variant has no fd field.
This is the core engineering value of Rust enum: it makes invalid states harder to represent.
match Must Handle Every Branch
C commonly uses switch:
switch (conn->state) {
case CONN_DISCONNECTED:
break;
case CONN_CONNECTING:
break;
case CONN_CONNECTED:
break;
case CONN_ERROR:
break;
}
Rust uses match:
fn describe(conn: &Connection) -> &'static str {
match conn {
Connection::Disconnected => "disconnected",
Connection::Connecting { .. } => "connecting",
Connection::Connected { .. } => "connected",
Connection::Error { .. } => "error",
}
}
A key feature is exhaustive checking. Every possible variant must be handled.
If a new state is added:
enum Connection {
Disconnected,
Connecting { retry_count: u8 },
Connected { fd: i32 },
Error { code: i32 },
Closing,
}
The old match no longer compiles until Closing is handled.
This differs from C switch. In C, a missing state may only be discovered when that path runs.
match Extracts State Data
Because each variant can carry data, match can extract it:
fn handle(conn: Connection) {
match conn {
Connection::Disconnected => {
println!("nothing to do");
}
Connection::Connecting { retry_count } => {
println!("retry count = {retry_count}");
}
Connection::Connected { fd } => {
println!("use fd {fd}");
}
Connection::Error { code } => {
println!("error code = {code}");
}
}
}
There is no chance of accidentally using fd in the error state. Only the Connected branch has fd.
This is useful for protocol parsing, device state machines, command handling, and error classification. Relationships that C code maintains with state codes and field conventions can often become enum structure in Rust.
Option Represents Maybe No Value
C often uses special values to mean “not found”:
int find_index(const int *items, size_t len, int target)
{
for (size_t i = 0; i < len; i++) {
if (items[i] == target) {
return (int)i;
}
}
return -1;
}
-1 is a convention. Callers must remember to check.
Rust uses Option:
fn find_index(items: &[i32], target: i32) -> Option<usize> {
for (index, item) in items.iter().enumerate() {
if *item == target {
return Some(index);
}
}
None
}
Option<usize> is an enum:
enum Option<T> {
Some(T),
None,
}
It puts “maybe no value” into the type instead of relying on -1, NULL, or a special pointer value.
The caller must handle both cases:
match find_index(&items, 7) {
Some(index) => println!("found at {index}"),
None => println!("not found"),
}
This is one of the most common examples of enum and match in the standard library.
Result Is Also an enum
The earlier error-handling article used Result. It is also an enum:
enum Result<T, E> {
Ok(T),
Err(E),
}
So Result and Option follow the same idea:
Option<T> = value Some(T) / no value None
Result<T, E> = success Ok(T) / failure Err(E)
This is clearer than C “return code plus output parameter” because success and error values each live in their own branch.
Do Not Overuse _
match can use _ for other cases:
match conn {
Connection::Connected { fd } => println!("fd = {fd}"),
_ => println!("not connected"),
}
This is convenient in many places.
But be careful in state machines and protocol handling. Using _ too early can swallow future states and weaken the value of exhaustive checking.
If each state has engineering meaning, prefer spelling out the branches. When a new state is added, the compiler will show which places need updates.
What to Keep from This Article
The first mapping is:
integer state code / macro constant -> enum variant
state code + optional fields -> enum carrying data
switch -> match
special -1 / NULL for no value -> Option<T>
return code + output parameter -> Result<T, E>
fear of missed new state -> exhaustive match checking
Rust enum is not just a replacement for C enum. It can put “state” and “data valid only in that state” together.
match is not just a modern switch. It forces all possible branches to be considered and lets each branch extract its own data.
The next article can cover traits: from C function pointers, callback tables, and interface conventions to Rust behavior abstraction.