In C, enum and union often appear together. The enum says which kind of value is active, and the union reuses the same storage for different payloads:
enum ValueKind {
VALUE_INT,
VALUE_FLOAT,
VALUE_TEXT,
};
struct Value {
enum ValueKind kind;
union {
int i;
float f;
const char *text;
} data;
};
This is a tagged union. It is common and useful, but correctness depends on discipline:
if (value->kind == VALUE_INT) {
printf("%d\n", value->data.i);
}
If kind is VALUE_TEXT and the code reads data.i, the C type system does not stop it. In Rust, this shape is usually represented with enum, where the tag and the data live in one type.
C enum Is Closer to Named Integers
C enum often replaces bare numbers:
enum State {
STATE_IDLE,
STATE_READING,
STATE_ERROR,
};
That is clearer than 0, 1, and 2. At the first level, think of it as a set of named integer constants. It is commonly used with switch:
switch (state) {
case STATE_IDLE:
break;
case STATE_READING:
break;
case STATE_ERROR:
break;
}
If a state only needs a name and no data, Rust can look similar:
enum State {
Idle,
Reading,
Error,
}
Branches are handled with match:
fn describe(state: State) -> &'static str {
match state {
State::Idle => "idle",
State::Reading => "reading",
State::Error => "error",
}
}
match performs exhaustive checking. If a new state is added, old matches must handle it before the code compiles.
C union Only Reuses Storage
C union means several fields share the same storage:
union Number {
int i;
float f;
};
It does not record which field is currently valid. Real programs therefore often wrap an enum and a union in a struct:
enum TokenKind {
TOKEN_INT,
TOKEN_IDENT,
};
struct Token {
enum TokenKind kind;
union {
int int_value;
const char *ident;
} data;
};
This is the tagged union pattern: kind is the tag, data is the payload.
The problem is that the relationship between tag and payload is still maintained by the programmer:
struct Token t;
t.kind = TOKEN_INT;
t.data.ident = "abc";
The object is wrong at the business level, but the C type shape cannot express “TOKEN_INT means only int_value is valid.”
Rust enum Binds Tag and Data
Rust represents this directly:
enum Token {
Int(i32),
Ident(String),
}
There is no separate kind field and no independent union data. Each variant carries its own data:
Token::Int(i32)has an integer valueToken::Ident(String)has identifier text
Construction is direct:
let a = Token::Int(42);
let b = Token::Ident(String::from("name"));
You cannot construct “tag is Int, but payload is a string,” because Rust does not split the tag and payload.
This is the main difference between Rust enum and C enum: Rust enum is closer to a safe tagged union.
match Extracts the Right Data
C checks the tag, then reads the union field by convention:
switch (token->kind) {
case TOKEN_INT:
printf("%d\n", token->data.int_value);
break;
case TOKEN_IDENT:
printf("%s\n", token->data.ident);
break;
}
Rust match checks and destructures at the same time:
fn print_token(token: &Token) {
match token {
Token::Int(value) => println!("{value}"),
Token::Ident(name) => println!("{name}"),
}
}
The Token::Int branch receives the integer. The Token::Ident branch receives the string. Branch and data stay together.
If a new token is added:
enum Token {
Int(i32),
Ident(String),
End,
}
The old match reports that Token::End is not handled. Missing updates are caught earlier than a forgotten C switch case at runtime.
State Machines Also Fit Data-Carrying enum
C state structs often look like this:
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 when disconnected? - Is
error_codevalid while connecting? - Does
retry_countmatter when connected? - Should
fdbe closed in the error state?
Rust can write the state directly:
enum Connection {
Disconnected,
Connecting { retry_count: u8 },
Connected { fd: i32 },
Error { code: i32 },
}
Now Disconnected has no fd, and only Error has code. Many invalid combinations cannot be represented by the type.
Option and Result Are enum Too
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;
}
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<T> is an enum:
enum Option<T> {
Some(T),
None,
}
Result is also an enum:
enum Result<T, E> {
Ok(T),
Err(E),
}
Both follow the same idea: do not hide “maybe no value” or “success versus failure” in special integers, null pointers, or output parameters. Put it in the type.
Rust Has union, but It Is Not the Default Tool
Rust also has union:
#[repr(C)]
union Number {
i: i32,
f: f32,
}
It is closer to C’s low-level layout tool than to normal application modeling. Reading a union field requires unsafe, because the compiler does not know which field is currently valid:
let n = Number { i: 42 };
unsafe {
println!("{}", n.i);
}
In ordinary Rust code, prefer Rust enum for tagged-union needs. Use #[repr(C)] union mainly for FFI, ABI compatibility, hardware register maps, and binary layout boundaries.
Do Not Reach for _ Too Early
match can use _ for other cases:
match token {
Token::Int(value) => println!("{value}"),
_ => println!("other"),
}
This is convenient, but be careful in protocols, state machines, and command types. Using _ too early can swallow future variants and weaken exhaustive checking.
If each variant has engineering meaning, spell out the branches. When a variant is added, let the compiler show the places that need updates.
What to Keep
The first mapping is:
C enum constants -> Rust enum variants without data
C switch -> Rust match
C union -> Rust union, mainly low-level/FFI
C enum + union tagged data -> Rust data-carrying enum
special -1 / NULL -> Option<T>
return code + output param -> Result<T, E>
Rust enum is not just a C enum with different syntax. It is closer to combining C’s tag, union payload, and branch-handling rules into one type, making invalid combinations harder to write.