Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-118 · Case file with fixtures · Case 90 of 694 · Compiler evidence

Why repr(transparent) Allows Only One Non-Zero-Sized Field

Transparent representation makes a wrapper use the layout and ABI of one non-zero-sized field. Additional state needs another representation or a separate Rust-side type, not a stronger transparent promise.

Reviewed
Rust
Rust 1.98.1
Targets
all targets; ABI follows the transparent field
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
Transparent representation needs one carrier whose layout and ABI the wrapper preserves, but the second stored field creates new representation.
First discriminating check
Identify the intended transparent carrier and decide whether additional state belongs outside the boundary type.

repr(transparent) is a strong promise: a wrapper has the same layout and ABI as one underlying field. The failing program adds both a u64 identifier and a u16 shard. Rust reports E0690 because two fields contribute storage.

There is no longer one field through which the wrapper can be transparent.

Transparent means one representation carrier

The Rust layout reference permits a struct or single-variant enum with one non-zero-sized field and any number of fields with size zero and alignment one. The type takes the layout and ABI of that one carrier field.

This supports newtypes around handles, integers, pointers, or foreign types. The wrapper can add Rust type safety without changing how the value crosses an ABI boundary.

It does not mean “try to lay fields out efficiently.” Adding a second stored value changes size, alignment possibilities, and calling convention, so transparency cannot hold.

Zero-sized fields can carry type information

A transparent wrapper may contain PhantomData or another qualifying zero-sized field. Such a field can affect variance, drop checking, or auto traits without adding ordinary runtime storage.

I still review it carefully. Zero size does not mean zero type-system meaning. A PhantomData<*const T> can change Send and Sync, for example.

The Nomicon's transparent representation section gives the FFI motivation and limitations. The representation guarantee and the semantic validity of values remain separate questions.

The repaired wrapper keeps one field

The repaired program uses struct UserId(u64) and asserts its size matches u64. It is a true transparent newtype.

If the shard is part of the value, I choose another design:

  • use a normal Rust struct and do not promise ABI transparency;
  • use repr(C) when interoperating with a matching C struct;
  • pack both logical pieces into one documented integer carrier;
  • keep transport metadata in a separate surrounding structure.

Packing into one integer introduces bit allocation, range, and endianness rules. It is not automatically better than a two-field struct. I use it only when the external format requires it.

Same layout is not automatic transmute permission

Even when two types share size and alignment, their valid bit patterns can differ. A transparent wrapper inherits representation from its field, but API invariants may restrict which values callers are allowed to construct.

For example, a transparent identifier might forbid zero. Exposing arbitrary transmutation from u64 can violate that semantic invariant even though layout matches. I provide checked constructors and document unsafe conversions separately.

Visibility also matters for the public ABI guarantee. I make the intended transparent field and FFI use explicit in documentation rather than asking consumers to infer it from current implementation.

ABI guarantees are target-aware

The carrier's ABI is the wrapper's ABI. A transparent wrapper around a pointer follows pointer ABI; one around a target-dependent type follows that target dependence.

I test C headers and Rust declarations together for every supported ABI. Bindgen output, calling conventions, symbol linkage, and ownership rules all matter beyond representation. repr(transparent) solves one part: how this value is represented when passed or stored.

It does not make a Rust reference safe for foreign code to retain, nor define who frees an owned pointer.

Avoid adding “small metadata” casually

A wrapper often begins as a transparent handle and later gains a flag, generation number, or cached property. That is an ABI change, even if the new field looks small. Rust catches the invalid attribute, but removing the attribute silently may still break foreign callers that relied on the old ABI.

I treat such a change as an API design event. A separate Rust-only rich type can contain the transparent handle and metadata while the boundary type remains stable.

Versioned FFI structs with explicit size fields are another option for evolving external contracts.

My debugging sequence

When E0690 appears, I do this:

  1. Identify the intended representation carrier.
  2. Calculate which fields have non-zero size or alignment greater than one.
  3. Decide whether extra state belongs in this boundary value.
  4. Keep one carrier for transparency or choose a different explicit representation.
  5. Audit value validity, ownership, and calling convention beyond size.
  6. Add layout and cross-language tests for supported targets.

The compiler is preserving the meaning of “transparent.” Once a wrapper stores two ordinary values, there is something new to represent. The correct design acknowledges that new state instead of asking one ABI promise to hide it.