# Different flavours of \`Enum\`

**URL:** <https://forum.crystal-lang.org/t/different-flavours-of-enum/9131>\
**Category:** Crystal Contrib\
**Tags:** rfc\
**Created:** [September 4, 2026, 5:38pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131 "2026-09-04T17:38:28Z")\
**Posts on this page:** 16\
**Page:** 1

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 4, 2026, 5:38pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/1 "2026-09-04T17:38:28Z")

</div>

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](https://github.com/crystal-lang/crystal/issues/7629), 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](https://github.com/crystal-lang/crystal/issues/9116)

Also related: [Socket::Family and friends should not be enums · Issue #7912 · crystal-lang/crystal · GitHub](https://github.com/crystal-lang/crystal/issues/7912#issuecomment-5516227469)

---

<div class="post-metadata">

**Author:** ![bcardiff](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/bcardiff/32/3_2.png) [@bcardiff](https://forum.crystal-lang.org/u/bcardiff)\
**Post date:** [September 5, 2026, 12:54am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/2 "2026-09-05T00:54:52Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 5, 2026, 8:54am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/3 "2026-09-05T08:54:03Z")

</div>

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?

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 5, 2026, 9:28am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/4 "2026-09-05T09:28:56Z")

</div>

> [@bcardiff](#):
>
> Allowing enum to be reopen leads to 3rd party code not compiling probably.

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

---

<div class="post-metadata">

**Author:** ![naqvis](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/naqvis/32/906_2.png) [@naqvis](https://forum.crystal-lang.org/u/naqvis)\
**Post date:** [September 6, 2026, 7:16am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/5 "2026-09-06T07:16:35Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 6, 2026, 7:45am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/6 "2026-09-06T07:45:13Z")

</div>

> [@naqvis](#):
>
> 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.

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 🤷  
I’m just proposing to make this difference more explicit in order to reduce confusion and increase compile-time safety.

> [@naqvis](#):
>
> 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.

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](https://github.com/crystal-lang/crystal/issues/7912#issuecomment-5516227469), 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.

---

<div class="post-metadata">

**Author:** ![naqvis](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/naqvis/32/906_2.png) [@naqvis](https://forum.crystal-lang.org/u/naqvis)\
**Post date:** [September 6, 2026, 8:33am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/7 "2026-09-06T08:33:27Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 6, 2026, 9:26pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/8 "2026-09-06T21:26:06Z")

</div>

> [@naqvis](#):
>
> - 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.

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

> [@naqvis](#):
>
> - Only then can `case ... in` drop its hidden runtime raise, because there’s nothing left to raise on.

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.

> [@naqvis](#):
>
> - That means `case ... in` has to start allowing an `else` for open enums, or rejecting the exhaustive form outright.

Explicitly open enums should not be eligible for exhaustive case.

> [@naqvis](#):
>
> 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.

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.

> [@naqvis](#):
>
> 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.

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.

---

<div class="post-metadata">

**Author:** ![naqvis](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/naqvis/32/906_2.png) [@naqvis](https://forum.crystal-lang.org/u/naqvis)\
**Post date:** [September 7, 2026, 4:56am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/9 "2026-09-07T04:56:43Z")

</div>

> [@straight-shoota](#):
>
> 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.

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.

> [@straight-shoota](#):
>
> Explicitly open enums should not be eligible for exhaustive case.

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.

> [@straight-shoota](#):
>
> 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.

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.

> [@straight-shoota](#):
>
> 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.

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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 7, 2026, 9:07am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/10 "2026-09-07T09:07:14Z")

</div>

Your responses have a strong LLM vibe.

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

> **[Don't paste the AI, please.](https://dontpastetheai.com/)**
>
> If someone asks you something, send your answer. Not the AI's.

---

<div class="post-metadata">

**Author:** ![naqvis](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/naqvis/32/906_2.png) [@naqvis](https://forum.crystal-lang.org/u/naqvis)\
**Post date:** [September 7, 2026, 9:15am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/11 "2026-09-07T09:15:31Z")

</div>

> [@straight-shoota](#):
>
> Your responses have a strong LLM vibe.

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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 7, 2026, 11:36am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/12 "2026-09-07T11:36:38Z")

</div>

> [@naqvis](#):
>
> If the writing reads like an LLM helped with it, that’s fine

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.

---

<div class="post-metadata">

**Author:** ![bcardiff](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/bcardiff/32/3_2.png) [@bcardiff](https://forum.crystal-lang.org/u/bcardiff)\
**Post date:** [September 7, 2026, 2:00pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/13 "2026-09-07T14:00:49Z")

</div>

> [@straight-shoota](#):
>
> I don’t think I understand what you mean with the module approach for open enums.

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

```crystal
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.

```auto
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.

---

<div class="post-metadata">

**Author:** ![magynhard](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/magynhard/32/3048_2.png) [@magynhard](https://forum.crystal-lang.org/u/magynhard)\
**Post date:** [September 8, 2026, 7:35pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/14 "2026-09-08T19:35:09Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Carlos](https://avatars.discourse-cdn.com/v4/letter/c/ecae2f/32.png) [@Carlos](https://forum.crystal-lang.org/u/Carlos)\
**Post date:** [September 9, 2026, 9:45pm UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/15 "2026-09-09T21:45:41Z")

</div>

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?

```crystal
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.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [September 10, 2026, 9:54am UTC](https://forum.crystal-lang.org/t/different-flavours-of-enum/9131/16 "2026-09-10T09:54:54Z")

</div>

A post was split to a new topic: [Multi-valued enum](https://forum.crystal-lang.org/t/multi-valued-enum/9140)
