Skip to content

Accept variants with no fields in the variant derives - #278

Merged
soareschen merged 1 commit into
mainfrom
derive-unit-variant
Oct 6, 2026
Merged

soareschen merged 1 commit into
mainfrom
derive-unit-variant

Conversation

@soareschen

Copy link
Copy Markdown
Collaborator

This PR lets the variant derives accept enum variants that have no fields. #[derive(CgpVariant)],
#[derive(CgpData)], #[derive(ExtractField)], and #[derive(FromVariant)] now take a variant
written Closed, Closed(), or Closed {}, and treat it as a variant whose payload is Nil. A
C-like enum, or an enum that mixes newtype and unit variants, can now be built, extracted, cast, and
dispatched like any other extensible variant. No input that compiled before changes its meaning: the
only inputs affected are the ones these derives used to reject.

Background: what the variant derives need

The variant derives view an enum as a sum of Field<Tag, Payload> entries, one per variant, keyed
by the variant's name. Each generated item names one payload type per variant: FromVariant's
type Value, the partial enum's variant, and the value each extractor hands back. Until now the
derives required every variant to carry exactly one unnamed field, so that field's type could serve
as the payload. A variant with no fields was rejected:

error: Expected variant to contain exactly one unnamed field

The usual workaround was a () payload, Closed(()). That changes the enum's own API for the sake
of the derive.

#[derive(HasFields)] already accepted every variant shape, and gave a variant with no fields the
empty product Nil as its entry. The other derives therefore disagreed with the shape that
HasFields and Enum! describe for the same enum.

What changes for the user

All three empty forms are accepted, and each carries the payload Nil. The payload agrees with
#[derive(HasFields)] and Enum!:

#[derive(CgpVariant)]
pub enum Status {
    Active(Count),
    Closed,
    Paused(),
    Archived {},
}

// <Status as HasFields>::Fields is
// Sum![
//     Field<Symbol!("Active"), Count>,
//     Field<Symbol!("Closed"), Nil>,
//     Field<Symbol!("Paused"), Nil>,
//     Field<Symbol!("Archived"), Nil>,
// ]

Every extensible-variant operation then works on these variants:

Operation Result for Status::Closed
Status::from_variant(PhantomData::<Symbol!("Closed")>, Nil) Status::Closed
owned to_extractor().extract_field(...) Ok(Nil)
extractor_ref().extract_field(...) Ok(&Nil)
extractor_mut().extract_field(...) Ok(&mut Nil)
upcast and downcast as for any variant

The dispatch matchers handle them, with one consequence to know. A value matcher such as
MatchWithValueHandlers passes the handler only the payload. Every empty variant therefore reaches
the same Nil handler, and the handler cannot tell which variant it got. A field matcher such as
MatchWithFieldHandlers passes Field<Symbol!("Closed"), Nil>, so its handler can read the
variant's name from the tag. The borrowed and mutable matchers work too.

Variants that still have no single payload are still rejected, with a message that now names
both accepted shapes:

Input Message
Pair(u32, u32) Expected variant to contain exactly one unnamed field, or no fields
Named { x: u32 } Expected variant to contain exactly one unnamed field, or no fields

How it works

The changes are in
derive_extractor/utils.rs,
with the owned, shared, and mutable extractor impls and the FromVariant impls updated to use it.

A VariantPayload value describes each variant once. get_variant_payload classifies a variant
as Single(&Type) for a newtype variant or Empty for any of the three empty forms, and rejects
every other shape. Each code generator asks the payload for the piece it needs: the payload type,
the match pattern, the constructor, the binding, and the value each extractor holds. A newtype
variant generates exactly what it did before.

The braced forms cover all three empty spellings. Rust lets Self::Closed { .. } match a unit,
an empty tuple, or an empty struct variant, and lets Self::Closed {} construct any of them. So the
derives emit one pattern and one constructor whatever form the user wrote. The ignored payload is
bound as _, which keeps the generated code free of unused-variable warnings without an #[allow]
attribute that would have changed every existing snapshot.

Each extractor needs a Nil value, and the mutable one needs care. The owned extractor holds
Nil itself, and the shared one holds &Nil, which Rust promotes to a 'static constant. Rust
does not promote &mut Nil, and the variant has no field to borrow mutably, so the mutable
extractor builds one with Box::leak(Box::new(Nil)). Nil is zero-sized, so Box::new never
allocates and the leak frees nothing. Expanded for Status:

// HasExtractorRef
Self::Closed { .. } => __PartialRefStatus::Closed(&Nil),

// HasExtractorMut
Self::Closed { .. } => __PartialRefStatus::Closed(Box::leak(Box::new(Nil))),

// FromVariant and from_extractor
__PartialStatus::Closed(_) => Self::Closed {},

Box is the one name the expansion takes from the caller's scope. Every other CGP name is
emitted through an exports marker, but cgp links no alloc and so has no Box to export, and
this PR does not add that dependency. The derives write Box bare, spanned at the empty variant.
A std crate finds it in its prelude. A no_std crate must import it, and an error without that
import points at the variant.

The borrowed field view FieldsRef is unchanged: an empty variant is Nil there rather than
&Nil, since a borrowed product is a product of references and the empty product has none.

Known issues

These limits are recorded in the knowledge base's reference documents for the derives and
#[cgp_auto_dispatch]:

  • A no_std crate must have Box in scope. Without it, the derive fails with
    E0433: cannot find type `Box` in this scope at the empty variant, and rustc suggests the
    alloc::boxed::Box import.
  • #[cgp_auto_dispatch] cannot serve an enum with an empty variant. The dispatched trait would
    need an impl for Nil, a type from cgp, and such an impl conflicts with the macro's blanket
    impl (E0119), as an impl for any foreign payload type such as u64 does. The dispatch matchers
    with a provider still work, because the crate owns the provider.

`CgpVariant`, `CgpData`, `ExtractField`, and `FromVariant` now take a
variant written `V`, `V()`, or `V {}` and give it the payload `Nil`,
matching `HasFields`. The mutable extractor builds its `&mut Nil` with
`Box::leak(Box::new(Nil))`, resolving `Box` in the caller's scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCBh7fScUH7fzq5DeNtyt
@soareschen soareschen added the AI label Oct 6, 2026
@soareschen
soareschen merged commit 69d2854 into main Oct 6, 2026
3 checks passed
@soareschen
soareschen deleted the derive-unit-variant branch October 6, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant