Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-374 · Case file with fixtures · Case 346 of 694 · Compiler evidence

repr(C) Does Not Support a Zero-Variant Rust Enum

A zero-variant enum has no valid values, while repr(C) asks Rust for a foreign-compatible enum representation. Keep an uninhabited enum Rust-only, or model an FFI opaque handle with pointers and an incomplete foreign type.

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
The C representation is unsupported for a zero-variant enum because C has no corresponding by-value enum representation with no valid values.
First discriminating check
Separate the Rust need for an uninhabited type from the FFI need for an opaque handle or a concrete tagged representation.

I have used an empty enum to represent an impossible Rust state. The surprise came when #[repr(C)] was added in an attempt to make the type suitable for an opaque FFI declaration. Rust rejected it with E0084.

The failing fixture contains only #[repr(C)] enum Never {}. No function constructs it, yet representation validation fails at the type declaration.

A zero-variant enum has no valid value

The enum Reference calls an enum with no variants a zero-variant enum. It cannot be instantiated through safe code because there is no constructor and no valid discriminant.

This makes the type useful inside Rust. A function receiving it can use an empty match, proving that control cannot reach a branch if the value is valid. Generic APIs can encode impossible alternatives without allocating or inventing a sentinel.

The repaired fixture removes repr(C) and demonstrates an exhaustive empty match. It does not attempt to create a Never value.

repr(C) asks a different question

repr(C) is about layout and ABI interoperability under the documented C representation rules. A C-style enum representation needs a set of representable values and a discriminant layout. A zero-variant Rust enum supplies no valid discriminants at all.

The E0084 explanation describes integer representations on zero-variant enums as unsupported. repr(C) has the same fundamental conflict: there is no by-value C enum corresponding to a Rust type with no possible values.

The absence of constructed values does not let rustc ignore the declaration. A representation attribute is a contract about the type itself, including uses in pointers, signatures, and containing layouts.

Opaque foreign types are passed indirectly

An opaque C library type usually means “clients cannot see the fields,” not “the type has no valid values.” C code owns real objects and exposes pointers such as struct engine *.

On the Rust side, I model the boundary around a raw pointer to an opaque marker type, following the Nomicon's opaque-struct guidance. The pointer is the FFI value with a known ABI. Rust does not pass the incomplete pointee by value or inspect its layout.

A wrapper can then enforce nullability, ownership, thread-safety, and destruction rules. Those properties do not come from making the marker enum empty.

Uninhabited is not the same as zero-sized

This distinction is easy to blur. A unit struct has size zero but one valid value. An empty enum has no valid value. An opaque foreign object generally has some unknown positive layout owned by the foreign library.

Replacing one with another can change control-flow assumptions and validity. PhantomData can express type relationships without owning storage, but it does not turn an FFI pointer into a safe handle by itself.

I write down which property I need:

  • impossible state inside Rust;
  • zero storage for a marker that can exist;
  • incomplete foreign pointee used only through pointers;
  • concrete C-compatible value passed across the ABI.

Each has a different representation.

Never fabricate a value for the empty enum

Unsafe code cannot make an invalid value acceptable by promising not to inspect it. Transmuting bytes, dereferencing a pointer as the empty enum, or calling a foreign function declared to return it by value can violate Rust's validity assumptions immediately.

Because the type has no valid values, the optimizer may treat code after obtaining one as unreachable. This is stronger than an unusual integer and can make an incorrect FFI signature dangerous even if a debug build appears to continue.

I audit generated bindings around incomplete types. Binding tools may choose an opaque struct representation or a private zero-sized marker used only behind pointers. I keep the exact generated convention consistent instead of replacing it casually with an empty repr(C) enum.

If the C API really returns a status, declare values

If a foreign type is actually a C enum with possible states, the Rust declaration needs corresponding variants and a verified ABI. Unknown future C values remain a versioning concern, so a fixed-width integer plus checked conversion can be safer than accepting a Rust enum directly from untrusted foreign code.

Adding a dummy variant merely to satisfy E0084 is correct only if that variant is a real valid state. Otherwise it changes an uninhabited model into an inhabited one and weakens every exhaustive argument built on impossibility.

My boundary test is about values and ownership

For FFI reviews I ask who allocates the object, which side destroys it, whether null is allowed, whether the pointer may be shared, and whether any value is ever passed by value. I verify sizes and signatures against the C header on supported targets.

For a Rust-only empty enum I compile the empty match and avoid representation promises. For an opaque handle I test pointer creation, null rejection, and exactly-once destruction with the real library or a small C fixture.

The core principle is that “opaque” and “impossible” describe different things. A zero-variant enum is a powerful Rust proof that no value exists. A C opaque type describes hidden layout for objects that do exist. E0084 prevents one syntax from pretending to satisfy both models.