Different flavours of `Enum`

There are a number of discussions about potential enhancements to the Enum type.

For example, the ability to reopen Enum types to add additional members has been requested in Reopening Enums · Issue #7629 · crystal-lang/crystal · GitHub, but it was rejected. I believe we should perhaps revisit that decision.

The primary argument against reopening is to have a fixed, final state of all enum members, so there can be no surprises when switching over them (e.g. in exhaustive case ... in).

A flaw in that argument is that it equates enum members with values. A member is just giving a name to a value. For example, member Colorize::Mode::Bold is a name for the value returned by Colorize::Mode.new(1).

For many (most?) enum types, the set of members contains all legal values. There exists no value that’s not described by a member.
But some enum types can have more values than named members. This is typically the case when an enum is not an original concept and reads values from an external source. For example error or status codes such as Errno or HTTP::Status. These types have a large domain of possible values, but only a fraction of them is represented as a named member. The rest might not be standardized, is typically irrelevant or platform specific. But that doesn’t mean such unnamed values can ever be observed.

Enum types typically allow instantiating arbitrary values through their numeric constructor.
So, if that is allowed, we can always be surprised by an unexpected enum value.
case ... in handles that by raising a runtime error.
Adding an additional member on the other hand would result in a compile-time error.
That’s actually a better error mode! We shouldn’t refuse that with an argument that it may break things, when the alternative might break as well but in a less obvious way.

However, it’s pretty uncommon to have an enum type with open values (e.g. which might be instantiated from numerical input) used in exhaustive case.

I’ve come to think that we can describe two different flavours of enums:

  • a fixed list of values, all expressed as members.
  • an open list of values, with some common ones as members.

(on top of that, there is also the @[Flags] flavour for flag enums)

Perhaps we could improve the enum features in the language to separate these use cases more clearly.

For example, an enum with a fixed set of values should reject instances with any other value making it impossible to get an unnamed value. It should not be allowed to reopen and add additional members.
An enum with open values does not need to be usable in exhaustive case. Reopening and adding additional members is fine.

As a mechanism to designate an enum type with fixed values, the @[Sealed] annotation could be considered. It has been proposed for general class inheritance, but would apply to this use case as well: [RFC] Introduce a @[Sealed] annotation · Issue #9116 · crystal-lang/crystal · GitHub

Also related: Socket::Family and friends should not be enums · Issue #7912 · crystal-lang/crystal · GitHub

I think that these “open enums” should be unions.

Allowing enum to be reopen leads to 3rd party code not compiling probably.

One way to have open unions is to use modules.

If we want some better syntax for “open enums” I would base it on module inclusion rather than enums. But I think that including modules could be enough.

I don’t think I understand what you mean with the module approach for open enums.
How would you implement HTTP::Status as a module?

I’m proposing two separate flavours where the ability to reopen and add members, and the ability to use in exhaustive case are disjunct.

You’ve found a real defect in the language, but I’d push back on the fix being two flavours of enum, because that’s a shape no language ended up with.

No language lets you add members to an existing enum. Java, Kotlin, C#, Rust, Swift, they’re all final. When a language needs a set that can grow inside a library, it reaches for a different construct, not an open enum. Kotlin and Java have sealed interfaces and classes for exactly this: new cases can be added within the same module and package, and when over them stays exhaustive for everyone outside. That works precisely because sealed types are structurally different from enums, so nobody confuses the two. Two flavours of the same enum keyword would be a confusing invention, and I can’t name a language where that was tried and stuck.

And for the truly open domains, like HTTP status codes, the mainstream answer is even blunter: they’re not enums at all. Rust’s reqwest models a status as a struct over u16 with named constants. Go uses a const block. Swift uses a plain Int. The code lists you mention, Errno, HTTP::Status, Socket::Family, are exactly the cases where every language that cared just said “don’t make this an enum”, which is what the Socket::Family issue is arguing.

So the defect you identified is real: a case ... in over an enum that can still hold values no member names looks exhaustive but hides a runtime raise, and no other language tolerates that contradiction, because they all picked one side. Either the enum is closed by construction, so construction of unnamed values is impossible and exhaustive matching is honest, or the open code list lives outside the enum type entirely. One kind of enum, with the open domains handled elsewhere.

If the real need is “I want my enum to grow inside my shard”, then the answer isn’t reopening, it’s what Kotlin and Java did: a separate sealed-style construct. If the real need is “I get unknown codes from the outside”, the answer is not making them enums in the first place.

Crystal already has two variants of enum: With @[Flags] annotation and without. That already defines a significant difference in behaviour.

And I’d argue that the two flavours that I’m describing in the OP already exist in practice. We actively use enum in these different ways, just not explicitly labelled. And it seems to work just fine :person_shrugging:
I’m just proposing to make this difference more explicit in order to reduce confusion and increase compile-time safety.

Yes, agreed. A struct value type is a valid mechanism to model these concepts.

However, Crystal ended up with using enum, for reasons that I cannot retrace at this point. But I believe that’s a good and valid choice.

As mentioned in Socket::Family and friends should not be enums · Issue #7912 · crystal-lang/crystal · GitHub, the general semantics and convenience features that come with enum, such as implicit predicate methods or iterating known values are pretty handy for these use cases.

Fair point on @[Flags], and it actually narrows my objection. One keyword plus an annotation changing semantics is already Crystal’s established pattern, so annotated flavours aren’t a confusing invention by themselves, and @[Sealed] would fit right into that pattern. What I was objecting to was the open flavour being tied to reopening members. Let me leave that aside, because there’s a sharper question underneath.

If the goal is compile-time safety, the interesting part is what each label changes in the compiler, and each flavour needs one specific rule before it delivers anything:

  • Fixed. To make exhaustive matching honest, the type has to stop holding unnamed values, and an annotation can’t do that on its own. You need a construction rule: Enum.new(n) and from_value become unavailable or fallible for fixed enums, like Swift’s init?(rawValue:) returning nil. Only then can case ... in drop its hidden runtime raise, because there’s nothing left to raise on. Without that rule, “fixed” is just documentation and the safety you’re after isn’t there.

  • Open. To make matching honest in the other direction, an exhaustive case ... in over all members of an open enum shouldn’t compile without a fallback, because the compiler knows the value domain is bigger than the member list. That means case ... in has to start allowing an else for open enums, or rejecting the exhaustive form outright. That’s a matching-rule change, not an enum-type change.

And your flags example forces one classification question. A flags enum’s legal values are all bit combinations, most of which are not members. Under the fixed flavour those values would be impossible, so every existing @[Flags] enum is open by definition. That’s fine, and flags even demonstrate that the open flavour already works in practice, since nobody exhaustively cases over flags, they bit-test. But the proposal has to say explicitly where flags land.

On status codes being enums: I agree the conveniences are the reason, and they don’t depend on the domain being open. status.ok? on an HTTP::Status value and iterating the known members work whether or not the type can hold unnamed values. The real question is where unknown codes live. If they should be representable, the enum is open and matches need fallbacks. If not, the enum is fixed and parsing is fallible at the boundary. That’s the actual fork, and it’s the part worth deciding per enum rather than per flavour label.

Yes, exactly. And other construction methods as well, such as Enum#+ and #- operators etc.

I don’t think we can ever drop the raise branch. We’ll always need to be prepared for encountering an invalid value, for example from unsafe code such as a pointer cast or memory corruption. The significant difference is that for a fixed enum, any unknown value is an invalid application state because there’s no legal way to obtain such a value.

Explicitly open enums should not be eligible for exhaustive case.

I don’t think I agree to that assessment. Flag enum’s combinations shouldn’t count as individual values. They are constructed from the underlying unique members.
Flag enums can be open or fixed as well. Regex::MatchOptions for example represents an option value passed to libpcre2. It should be open, so you can inject additional options which are not represented in the Crystal bindings, but the library might still understand.
On the other hand File::MatchOptions is an entirely internal concept and it should be fixed. There’s no sense in representing any other values than the defined members.

I don’t understand what you mean by “deciding per enum” and why that would be different from the proposed flavors? The flavor annotation would serve exactly the purpose to decide per enum whether it can represent unnamed values or not.

mostly agree the branch should stay, and your framing of the difference is right: for a fixed enum an unknown value is an invalid application state, while for an open one it’s legal input the caller didn’t handle. The one thing I’d push on gently is the reasoning. If pointer casts and memory corruption justify a runtime check here, the same logic would justify one for every value of every type, and Crystal doesn’t defend against corruption elsewhere, unsafe is the caller’s responsibility. So I’d see the raise as an implementation detail, a cheap assertion that legal code can never reach, rather than something load-bearing. And if legal code really can’t reach it, then the runtime behavior of fixed enums ends up nearly identical to today, and the value of the flavour is mostly compile-time. That’s fine, I just think it’s worth saying explicitly, so we don’t oversell what the annotation changes at runtime.

Agreed. When the domain is open, totality over the member list is meaningless, so an exhaustive case ... in over an open enum should be a compile error and callers use when/else with a fallback. That matches how C-like languages handle it, where totality is never promised.

You’re right, and my blanket claim was wrong. Combinations are derived from members, not unnamed values, so the fixed/open question for a flags enum is about unknown bits, not about unions of defined ones. Regex::MatchOptions open versus File::MatchOptions fixed is a good demonstration that the labels are orthogonal to @[Flags] and meaningful in both positions.

Mechanically it isn’t different. The annotation is the per-enum decision, and it answers one question: whether unnamed values are legal for that type. Nothing more.

Your responses have a strong LLM vibe.

I wont partake in this exchange until I’m sure I’m talking to a human.

If the writing reads like an LLM helped with it, that’s fine, but it doesn’t change anything about the content, and I’d rather the discussion stay on the technical claims.

If you think any of them are wrong, that’s the part worth engaging with, and I’ll defend or correct them on the evidence. If the style is a dealbreaker regardless, then there’s no discussion to be had here, and that’s your call to make.

Your comments read like I’m talking to an LLM. And that’s not fine. I ain’t got time for that.

If I want to hear an LLM opinion, I’ll prompt it myself.

Assuming we have types Foo and Bar, we can have “closed unions”:

alias Closed = Foo | Bar

You can’t add new values to it without changing the definition of Closed.

But if we use a module it acts as an “open union” because you can extend it without affecting previous definitions.

module Open
end

class Foo
  include Open
end

class Bar
  include Open
end

# Open can be used as Foo | Bar

This works for structs and class. What I didn’t realise is that enum don’t support include. That seems a bit arbitrary.

Assuming we can do it for enums we could have a module AllValues be included in an enum KnownValues and we can either declare more enums or strings or symbols if we want arbitrary values. Or struct wrappers if we want to allow just some strings.

I was thinking about, what solution would feel most intuitive. At first I was thinking about @[Sealed] and even introducing explizit @[Open] to stay backward compatible with the current behaviour when not using any annotation.

But then i was thinking: What about introducing a new Type OpenEnum which has also the option for @[Flags] annotation, so is identical to Enum, but can be extended like described (redefining with a new type) and to make the current Enum a sealed one by default, like it is in other languages? Of course with some transition time and deprecated messages, if the compiler detects someone is using numbers, that have not be defined as name.

Because I think reusing Sealed for an Enum is not very common and should not be mixed in here if there isn’t a urgent reason for.

Edit: As it is currently enum lowercase, maybe something like onum would make more sense.

In my opinion, Enums shouldn’t be reopened. But an “open enum” is basically a struct with some constants and predicate methods, isn’t it?

struct MyEnum
  getter value : Int32

  def initialize (@value)
  end

  One = new(1)
  Two = new(2)

  def one?
    self == One
  end

  def two?
    self == Two
  end
end

struct MyEnum
  Three = new(3)

  def three?
    self == Three
  end
end

So maybe encourage this pattern and facilitate it with some macros, wdyt?

The magic Symbol → Enum autocasting would be missing though.