Enum variants: a small problem, a lot of headache and boilerplate

Enum variants can create a surprisingly annoying problem when they contain unrelated types that happen to support the same operation.

Consider:

enum Value {
    Foo(Foo),
    Bar(Bar),
}

Both Foo and Bar have a value() method, but they are otherwise unrelated types.

The straightforward Rust solution is:

match value {
    Value::Foo(x) => x.value(),
    Value::Bar(x) => x.value(),
}

Nothing is wrong with this code. The problem starts when the enum grows, or when you need to perform many different operations this way.

You end up repeatedly writing essentially the same match:

match value {
    Value::Foo(x) => x.some_operation(),
    Value::Bar(x) => x.some_operation(),
}

A common trait can solve some cases, but sometimes these types don't actually represent a useful common abstraction. Introducing a trait only to avoid repeating match arms feels like solving a syntactic problem by changing the type design.

dyn Trait also isn't what I wanted here. The enum already provides static dispatch, and I wanted to preserve concrete types in every branch.

I started wondering whether Rust could express something closer to:

match_variants!(Value, value, (x), {
    x.some_operation()
})

and have it expand into:

match value {
    Value::Foo(x) => x.some_operation(),
    Value::Bar(x) => x.some_operation(),
}

The interesting part is that x has a different concrete type in every generated match arm, so the expression can be independently type-checked for each variant. No common trait or dynamic dispatch is necessary.

This turned into an interesting proc-macro problem of its own.

A function-like proc macro can see the expression passed to it, but it can't ask rustc which variants belong to an arbitrary enum type. A derive macro can see all the variants, but it doesn't know what expression will later be applied to them.

The solution I ended up experimenting with is combining the two: #[derive(MatchVariants)] records the variant information in a generated helper macro, and match_variants! later uses that information to generate the actual match.

I packaged the experiment as match-variants:

https://crates.io/crates/match-variants

Source:

I'm curious how other Rust developers approach this pattern. Do you usually accept the repeated matches, introduce a trait, use another enum-dispatch pattern, or have you run into cases where neither option felt particularly satisfying?

I'm definitely impressed by the design challenges you managed to solve here! The two-macro system in particular is pretty cool. import_match_variants! is impressive foresight (and a clever trick) as well.

However, I think enum_dispatch more or less solves this problem already. I understand that you've described differences between this crate and yours (implicitly here and explicitly elsewhere), but I honestly believe the overlap is more significant than you think. The crux is in this section in your crate's README:

A traditional solution is to define a trait implemented by every payload type and dispatch through that trait. [...] That is often the right design when the types genuinely share an abstraction. match-variants targets a different case: the types are unrelated, but a particular piece of code happens to be valid for every variant.

I think this argument puts you between a rock and a hard place:

  • On one side, there may be an actual abstraction, in which case defining a trait and using enum-dispatch is a more idiomatic solution since it captures that abstraction in the type system. (As you said.)
  • On the other side, the names may simply be identical by coincidence. If that's true, I don't think using a macro to auto-generate match arms is a good idea in the first place. Practically, it's fragile to renamed methods. Semantically, the macro seems to imply that the methods are related, with they aren't.

In fact, I'd argue that multiple match arms in the second case aren't boilerplate at all. Even though they're calling methods with the same name, they are meaningfully different methods (since they don't have a common abstraction), so it's meaningful that the arms are separate from each other.

I can see benefits to this crate over enum_dispatch; in particular, enum_dispatch creates From and TryFrom implementations that might be unwanted in extremely (and I do mean extremely, possibly to the point of nonexistence) bloat-sensitive environments. But for the majority of cases, I think that either a trait is a good idea or an auto-generated match is not.

I agree with you that enum_dispatch can solve majority of the troubles, but not all of them.

Before I've started working on this proc macro, I've played with enum_dispatch but then ended up with some issues. Made another release recently, putted few into README.md why not enum_dispatch.

In few words:

it cannot handle — for example, when the trait involves associated types (type T = ...), associated functions without self, or methods involving Self where the concrete implementation type matters. It also doesn't cover cases where there is no useful trait abstraction in the first place, such as an enum of unit variants representing different associated types.

Unit variants another things, that enum_dispatch cannot handle. I am working on update right now to support unit variants nicely.

The goal is therefore not to replace enum_dispatch, but to cover cases where trait-based enum delegation is either unsupported or simply not the abstraction you want.

Your solution Is smart but I'd think again about a trait. You have the discriminant values of an enum and all of them have a method with the same name, same arga and same return values - that Is pretty much my definition of a trait!

I'm curious how other Rust developers approach this pattern. Do you usually accept the repeated matches, introduce a trait, use another enum-dispatch pattern, or have you run into cases where neither option felt particularly satisfying?

I ran into a very similar problem, and solved it in a very similar way. (And like you, I tried using enum_dispatch, and like you ran into some of its not-yet-implement edge cases.)

My use-case is essentially a philosophical one: libraries should be open to extension (traits), but applications should be closed for efficient dispatch (enums).

Given:

// mylib/src/lib.rs
struct Foo { ... }
struct Bar { ... }

trait MyTrait { ... }
impl MyTrait for Foo { ... }
impl MyTrait for Bar { ... }

fn do_stuff<T: MyTrait>(t: T) { ... }

we'd like to obtain:

// myapp/src/main.rs
use mylib::{Foo, Bar, MyTrait, do_stuff};

#[some_kind_of_macro_magic]
enum MyEnum {
    Foo,
    Bar,
}

fn main() {
    let t: MyEnum = ...;
    do_stuff(t)
}

I couldn't find any existing package that supported this use-case (in particular enum_dispatch does not support associated types).

My solution was just to vibe-code the above macro-magic into existence, including support for associated types.

When it comes to enum_dispatch, I feel this may be a more fundamental limitation than simply something that has not been implemented yet.

When you're dispatching from an enum, you normally need an instance of that enum so you can inspect the variant and determine where the dispatch should go.

If a trait contains something that cannot be delegated based on an enum instance — associated types are a good example — then there is no runtime variant available to tell the macro which concrete type should be selected. Perhaps enum_dispatch could provide some way to configure that relationship explicitly, rather than trying to infer or automate it.

Yesterday evening I made another release of my macro to handle unit variants as well. For example:

struct Foo;
struct Bar;

#[derive(MatchVariants)]
enum Value {
    #[variant_type(Foo)]
    Foo,

    #[variant_type(Bar)]
    Bar,
}

let result = match_variants!(Value, value, type T, {
    process::<T>()
});

This literally expands into something equivalent to:

match value {
    Value::Foo => {
        type T = Foo;
        process::<T>()
    }
    Value::Bar => {
        type T = Bar;
        process::<T>()
    }
}

So in this case the enum variant is used to select a concrete type at compile time inside each generated match arm.