RFA-372 · Case file with fixtures · Case 344 of 694 · Compiler evidence
An Explicit Enum Discriminant Can Collide With an Implicit Zero
Rust assigns discriminants to unnumbered variants too: zero for the first, then the previous value plus one. Audit the effective table, not only visible equals signs, especially for protocol and FFI enums.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Unnumbered variants still receive discriminants, starting at zero or continuing from the previous value, so an explicit value can duplicate an implicit assignment.
- First discriminating check
- Write down every effective discriminant, including implicit ones, and give protocol-facing variants unique explicit values.
I have seen enum numbering reviewed by looking only at the variants containing =. That misses half of the table. Rust assigns a discriminant to every variant, including the ones whose values are not written.
The failing program declares Waiting first, without a number, and then Running = 0. Rust reports E0081 because both variants have effective discriminant zero.
Implicit does not mean absent
The enum discriminant rules give an unnumbered first variant the value zero. A later unnumbered variant receives one more than the preceding variant.
For this declaration:
enum Stage {
Waiting,
Running = 0,
}
the complete table is Waiting = 0, Running = 0. The visual difference between an omitted expression and an explicit expression does not create a semantic difference in the resulting value.
Rust requires discriminants to be unique. The E0081 explanation notes that clashes can involve variants that look unrelated because automatic numbering has already occupied a value.
Mixed numbering creates hidden dependencies
Suppose an enum begins Unknown = 255, Ready, Busy. Now Ready is not zero; its implicit value follows 255, and with repr(u8) it cannot even be represented. Or suppose a new variant is inserted before an implicit run. Every later implicit value can move.
This makes mixed numbering risky for protocols, database values, telemetry codes, and FFI. A source edit that looks like reordering names can change external integers or fail because of a collision.
For an internal enum whose numbers never leave the program, implicit discriminants are often the clearest choice. I do not expose their numeric casts as stable identifiers. For an external contract, I normally assign every value explicitly and test the mapping.
repr chooses representation, not uniqueness policy
Adding #[repr(u8)] selects a primitive representation under the documented rules. It does not permit aliases. Rust enums model exactly one declared variant at a time, and duplicate values would make a numeric representation unable to distinguish two variant constructors.
Other languages and schema formats sometimes allow several symbolic names for the same integer. I do not translate that mechanically into two Rust variants with one discriminant. Instead I keep one canonical Rust variant and accept aliases in the parser, or represent the raw integer separately from the normalized domain enum.
This preserves an important property: after validation, one Rust value has one unambiguous variant identity.
The repair depends on whether numbers are public
The repaired fixture gives Waiting = 0 and Running = 1, then asserts the casts. This is appropriate for a tiny fixed numeric contract.
If the numbers have no external meaning, I remove all explicit assignments and let Rust number the enum. I also avoid testing exact casts, because that would turn an implementation detail into a contract.
If multiple external numbers should map to one state, I parse them:
match raw {
0 | 10 => Stage::Waiting,
1 => Stage::Running,
_ => return Err(UnknownStage(raw)),
}
The alias exists at the boundary, while the domain model stays canonical.
Do not transmute unchecked integers into enums
A primitive representation does not make every integer a valid enum value. Creating a Rust enum with a discriminant not declared by the type violates its validity rules. Incoming bytes should remain integers until validation succeeds.
I use TryFrom<u8> or a parsing function that returns an error containing the unknown raw value. This also gives versioning a place to live. An older reader can preserve or reject a newer code without pretending that it is a valid old variant.
For C interfaces, I check the exact ABI and header rather than assuming repr(u8) matches a C enum, whose representation is controlled by the C implementation. Fixed-width integer parameters plus validation are frequently easier to evolve.
Tests should cover the complete effective table
For contract-facing enums I maintain a table test with every variant and expected integer. I also test unknown input. The compiler catches duplicates in the Rust declaration, but it cannot know whether a unique number is the correct number for my protocol.
When code generation owns the enum, I validate schema IDs before emitting Rust. This produces a domain-specific error instead of making the generated crate the first place a collision becomes visible.
In review, I ask three questions:
- Which values are implicit after applying Rust's rules?
- Are the numbers observable outside this build?
- What happens when an unknown or aliased value arrives?
The core principle is that omission is still a value assignment. E0081 is not complaining about syntax; it is showing that the completed discriminant table contains two identical entries. Writing or deliberately hiding the whole table makes enum evolution much safer.