Types as enum variants

There's been a few attempts [1] [2] to allow using enum variants as types. These were rejected or postponed due to various issues and concerns of feature creep.

The main reason why this feature would be useful is to be able to pass specific enum variants as function arguments. Right now there's two ways:

  1. Use the enum in the function signature, but expect it to always be the right variant:
    fn use_cat(cat: Animal) {
        match cat {
            Cat { meow } => { },
            _ => unreachable!(),
        }
    }
    
  2. Emulate variants as types with structs:
    struct Cat {
        meow: bool,
    }
    
    struct Dog {
        bark: bool,
    }
    
    enum Animal {
        Cat(Cat),
        Dog(Dog),
    }
    

What if the second approach was "blessed" with syntactic sugar to make it more convenient to use? Consider the following:

struct Foo {
    foo: i8,
}

mod bar {
    struct Bar {
        bar: i32,
    }
}

// Desugars into:
// enum FooBar {
//    Foo(Foo),
//    Bar(bar::Bar),
//    Both(Foo, Bar),
//    SpecialFoo(Foo),
// }
enum FooBar {
    type Foo, // Use type as a variant
    type bar::Bar, // Supports arbitrary paths
    Both(Foo, Bar), // Can mix normal variants
    SpecialFoo(Foo), // Can mix normal variants
}

fn assign() {
    // Normal syntax works
    let foo1 = FooBar::Foo(Foo { 1 });

    // Desugars into:
    // let foo2 = FooBar::Bar(bar::Bar { 2 });
    let foo2 = FooBar::Bar { 2 };

    // Is this too "clever"?
    let foo3: FooBar = Foo { 3 };

    // Or this?
    let foo4: FooBar = bar::Bar { 4 };
}

fn use_foo(foo: Foo) {}
fn use_bar(bar: Bar) {}

fn match(fb: FooBar) {
    match &fb {
        // Desugars into:
        // FooBar::Foo(f)
        Foo f => { use_foo(f); },
        
        // Desugars into:
        // FooBar::Bar(b)
        bar::Bar b => { use_bar(f); },
        
        // Normal variants work as usual
        FooBar::Both(f, b) => { },
        SpecialFoo(f) => { },
    }

    match &fb {
        // Allow destructuring as if it was a normal enum variant.
        // Desugars into:
        // FooBar::Foo(f) => { println!("{}", f.value); },
        FooBar::Foo { value } => { println!("{value}"); },
        _ => { },
    }
}

fn if_let(fb: FooBar) {
    // Desugars into:
    // if let FooBar::Foo(f) = fb { }
    if let Foo f = fb { }

    // Desugars into:
    // if let FooBar::Bar(b) = fb { }
    if let bar::Bar b = fb { }
}

This is backwards compatible and transparent to downstream users. Enum variants with payload can be freely "promoted" into structs without breaking any existing code.

This approach adresses some of the concerns around feature creep in the previous attempts, such as what's described in this comment:

  • Because these are regular structs, all normal functionality such as implementing traits on them is already supported.
  • Arbitrary subsets of variants can be defined as distinct enums reusing the same structs as variants.

One drawback compared to the latest RFC attempt I can identify is there being a coercion cost, unlike if enum variants themselves were used as types with the discriminant "hardcoded" to them.

Types as enum variants on type system level allow some potentially interesting extensions. I don't know how useful this would be in practise though:

trait Sound {
    fn sound();
}

struct Cat;
impl Sound for Cat {
    fn sound() {
        println!("meow");
    }
}

struct Dog;
impl Sound for Dog {
    fn sound() {
        println!("bark");
    }
}

enum Animal {
    type Cat,
    type Dog,
}

// Emits an error if:
// 1. Any variant isn't a type variant, or
// 2. Any type variant doesn't implement the trait
// Generates a trait implementation with every function consisting of a single exhaustive match block.
// "virtual" because it seemed the most fitting of all the keywords available (to me).
impl virtual Sound for Animal;

fn make_sound(animal: Animal) {
    animal.sound();
}

fn test() {
    // Prints "meow"
    test(Cat);
}
4 Likes

One potentially interesting new direction here: https://github.com/rust-lang/rust/pull/107606

Right now that's just for integers (type u7 = u8 match 0..=127;), but if it's subsets that match a pattern there's no reason it couldn't work for enum variants too.

That'd even allow things like type NotEqual = cmp::Ordering match (.Less | .Greater);, where it's a subset of the enum but not just one variant.

And in an arm like x @ Some(_) => there's something wonderfully obvious about x having type Option<_> match Some(_).


Oh, another thought:

Pattern types like this would make it really easy to offer some more guaranteed layout optimizations. For example, there's a nice obvious representation for type HResult = Result<i32 match 0.., i32 match ..0>; since the two sides share the same base type and the patterns are non-overlapping.

Similarly, Result<i8 match 0..=100, i8 match -1> might not be exhaustive but that's ok, they don't overlap so guaranteeing it's repr(i8) feels potentially plausible.

18 Likes

How would this work in function signatures when it's the common case of only a single enum variant?

Could you write some kind of a weird pattern match?

fn foo(_: Foo @ Bar { a, b }) {}

Or do you need infallible match to access the data?

fn foo(bar: Foo match .Bar) {
    match bar {
        Foo::Bar { a, b }
    }
}

Really I'd prefer being able to just name the variant (but then we're talking about enum variants as types again):

fn foo(bar: Foo::Bar) { }
1 Like

Well, function parameters can be infallible patterns, so this would work:

fn foo(.Bar { a, b}: Foo match .Bar { .. }) { … }

Would there be something else too? Maybe.

(I don't have a concrete proposal, just exploring possibilities.)

Why do variant types need special syntax?

The straightforward syntax IMO is:

fn foo(bar: MyEnum::VariantA) { ... }

To get a particular variant type, use TryFrom/TryInto:

let bar = MyEnum::some_variant();
foo(bar.try_into()?)
6 Likes

One reason would be polymorphic variants: how do you make the same variant a member of multiple enums? The ability to "import" freestanding types into enums is also useful in the context of third-party libraries e.g. delegative error types (where one of the variants just stores a library's own error type).

I struggle to see how your proposal would be useful for any of that. All it seems to provide is some minor syntactic sugar for converting an inner type to a variant (because it would be a no-op).

I was surprised to find no likes on this thread. Most newtype variant proposals I see propose creating types that represent the enums at a specific variant (e.g. Option::Some being a refinement type meaning an Option that is Some), which tend to run into backwards compatibility issues with type inference.

To me, this is a creative solution which solves the ergonomical motivations for enum variant types without all of the other baggage. Far too many times have I had to write something like

let item = ast::Item::Func(ast::ItemFunc { params, block });

and

match item {
    ast::Item::Func(ast::ItemFunc { params, block }) => { ... },
    // ...
}

when in this proposal these could be

let item = ast::ItemFunc { params, block };

match item {
    ast::ItemFunc { params, block } => { ... },
    // ...
}

I am not sure about the Foo foo => ... syntax, but this is just a bikeshed. To my knowledge rust has always avoided using a space as a delimiter between two arbitrary (non-keyword) identifiers.

Main question I guess is; this proposal is a fair bit less ambitious than most enum variant proposals, so is the payoff still big enough? As you mention in the OP, there are now coercion costs, but I'm also not certain that these are a big problem.


It's not a no-op. The enum has a discriminant, the inner types do not.

2 Likes

That seems easy enough to solve today by using an inner struct with a 1-tuple variant, which is the straightforward solution to reusing a variant across enums.

I'm not sure what you're proposing to do there differently other than adding a similarly verbose syntax which is redundant with existing features?

My proposal elevates variants to first-class types that the type system can reason about. It's not "minor syntactic sugar", it's something you can't do today with Rust's type system (i.e. reason about enum variants with the type system and name them as types)

1 Like

Matching against types could use the exact same syntax as matching against enum variants now, so no new syntax to bikeshed is needed there. (Foo(f) => {...})

I had a likely way too complicated and overkill idea about coercion costs:

As long as the types used are repr(rust), the compiler could decide to include space for the discriminant in the struct layout. There could also be a new attribute to explicitly require this and define the type's "global" discriminant.

If an enum only consists of variant types with the discriminant in their layout, and their "global" discriminants are all different (either generated by the compiler or explicitly defined), the enum layout could be completely transparent, and conversions between the enum type and any of its variants no-op.

1 Like

Both "variants as types" and "types as variants" have been discussed at length before, and I don't really see any new information in this thread.

"Types as variants" only serves as a bit of syntax sugar over using nominal newtype variants. The real difficulty comes from the semantics of the introduced coercions. There's also the question of what it you use two types with the same "final fragment" name; it can't be purely syntax sugar for a newtype variant with the same name, because then you'd get name collisions where there's no reason to.

I'm generally in favor of both "types as variants" and "variants as types;" both have their uses. But I also think it's quite unlikely to make any progress just by discussing syntax or even semantics in the abstract; the way forward is to decide whether the general direction is worth exploring and then doing an experimental implementation to actually properly learn about the complexities involved. "Variants as types" is finally making progress along that line with pattern refined types; with a motivated enough contributor I believe "types as variants" could also make progress.

2 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.