Hacker News new | past | comments | ask | show | jobs | submit login

I'd add one more to this list: proper enum types.

We use enums heavily to force devs who use our code into good choices, but the options are currently:

1) Use int-type enums with iota: no human-readable error values, no compile-time guard against illegal enum values, no exhaustive `switch`, and no autogenerated ValidEnumValues for validation at runtime (we instead need to create ValidEnumValues and remember to update it every time a new enum value is added).

2) Use string-type enums: human-readable error values, but same problems with no compile-time guards, exhaustive switch, or validation at runtime.

3) Use struct-based enums per https://threedots.tech/post/safer-enums-in-go/ : human-readable error values and an okay compile-time check (only the all-default-values struct or the values we define), but it still doesn't have exhaustive switch, is a complex pattern so most people don't know to use it, and suffers from the mutable `var` issues the post author detailed.

To my naive eye, it seems like a built-in, compile time-checked enum type with a FromString() function would help the community tremendously.




Just adding to the discussion:

I find this comment from Griesemer [0] on one of the github issues for enums in Golang quite insightful:

>> [...] all the proposals on enums I've seen so far, including this one, mix way too many things together in my mind. [...] Instead, I suggest that we try to address these (the enum) properties individually. If we had a mechanism in the language for immutable values (a big "if"), and a mechanism to concisely define new values (more on that below), than an "enum" is simply a mechanism to lump together a list of values of a given type such that the compiler can do compile-time validation.

Like with generics, I like the team's approach of taking features seriously, not adding them just because other languages have them, but actually trying to figure out a way for them to work in Go, as cleanly as possible. I think computer science, as a field, benefits from this approach.

And I also dislike many things from Go, and I want "enums" badly too, but that's for another comment.

[0] https://github.com/golang/go/issues/28987#issuecomment-49679...


Personally I’ve run into more problems with strict enum types in distributed systems in a team setting, than I have with Go’s lack of them. In that setting, strict enums are usually over-strict and eventually you back yourself into a corner in terms of being able to roll out new enum values.

When there’s no clear winner in terms of tradeoffs, I prefer to leave it out of the language like Go has done.


> strict enums are usually over-strict and eventually you back yourself into a corner in terms of being able to roll out new enum values.

Yes, they are poison for the evolution of a public API.


Swift solved this problems with [non-exhaustive enums](https://github.com/apple/swift-evolution/blob/main/proposals...).


Is it solved? How do you decide which enums are final and which ones need to be non-exhaustive to allow evolving the code?

I think that Go's decision stems from protocol buffers as they allow to push new values through old binaries, which is a must once you grow enough.

https://developers.google.com/protocol-buffers/docs/proto3#e...


Well, one thing is that enums are non-frozen by default, so you have to actively tag it as `frozen` if you want to put yourself in a scenario where you're never allowed to add cases.

When clients use `switch` on a non-frozen enum from outside its defining module, Swift emits a warning if they don't have an `@unknown default:` case... so consumers of your enum will have to have default logic for handling new cases in order to avoid this warning. (Not for frozen enums though, for frozen ones it's enough to just cover the known cases in calling code, since the expectation will be that you can't update them.)

So basically, if you don't bother thinking much about the problem, you can just avoid adding `frozen` and you'll probably get reasonable behavior where you can add more cases later. Using `frozen` should only be the case if there is some sort of logical impossibility for there to be more cases. Something like how `Optional` has .some and .none, but it's pretty obvious that nobody's going to go add a new case to it (what would a new case even mean?) Same with Result, and probably a bunch of other types I can't think of at the moment.

Also worth noting that Swift treats intra-library code very differently than code that links from another library... if you use your own enums in your own module and don't make them public, it treats them as if they're always frozen... which is nice because it's your internal code and you can always update your own usages without having to worry about compatibility.


> Well, one thing is that enums are non-frozen by default, so you have to actively tag it as `frozen` if you want to put yourself in a scenario where you're never allowed to add cases.

Yeah, non-frozen by default makes a lot of sense. The only gotcha left is that you can't retract from adding frozen, but that's ultimately behavior you want and something that must be able to bite you back.

> if you use your own enums in your own module and don't make them public, it treats them as if they're always frozen... which is nice because it's your internal code and you can always update your own usages without having to worry about compatibility.

That's neat


> which is nice because it's your internal code and you can always update your own usages without having to worry about compatibility.

Unless your services talk to each other or share some external data storage? Which is actually really common?


Services talking to each other, and storage persistence, are only tangentially related to internally defined enums. At some point you need to marshal data in and out of a serialization boundary, and it is at that point that you must handle cases you didn’t anticipate. But it’s just serialized data; it may be intended to represent the same value your enum describes, but it’s up to the deserialization code to do the right thing if it encounters a value it doesn’t recognize.

What I mean is, code that deals with serialization cannot by definition avoid the problem of “what if the data is invalid”. It’s not just enums but every aspect of your type system that must deal with this problem (typically by just throwing an error if the data is invalid, etc.)


"Invalid" is different than "unknown but safely be round-trippable" though. We round-trip unknown-to-the-local-unmarshaller enums through our protobuf services or DB layers all the time.


If you want to carry marshaled data around without knowing what is, carry the marshaled data around. If you want to know what that data is and deal with it, marshal it into a known swift enum.

I honestly think we’re talking about different things… the guarantees a programming language gives you are independent of the guarantees a serialization format gives you.


That's a fair concern: if everything is strict, then there's no option to incrementally roll out a new value. Maybe a proper enum type could always have an `Unknown` value, which would allow for the leniency while still forcing the use to think about (and handle) it at compile time?


Rust supports #[non_exhaustive] attributes, forcing users to cover the generic/wildcard case even though you have already covered all existing ones. Although, I rather do versioning and a breaking change if possible. Put it on the parsing/interop level rather than deep in the code during runtime because it is very likely that your code is not correct without handling the extra case either way.

https://doc.rust-lang.org/reference/attributes/type_system.h...


Oooor the language can have proper type-safe enumerated types of some sort, and if you're in a domain where that's an issue you don't use them.


Go is focused on the distributed systems domain though. It's fine, even desirable, to have languages focused on particular domains, that make design decisions based on the constraints of the domain. In this domain, closed enums are footguns with costly consequences if you get it wrong.


As someone that doesn't work in that domain, could you give a short example?


You push Thrift clients out into the world expecting to a certain API field to be typed according to a 3-element enum. You add a 4th element to support a new feature in a new client. If you ever accidentally serve this 4th element to an old client, it will crash on deserialization. Bonus points if the client is old enough that it's not part of your testing regime anymore.


I would be okay with an open-by-default enum type. (I think...I'm not sure I've ever encountered a language with open-by-default enums.)

I'm still not sure if it's worth it, it's idiomatic Go to "fall-through" if-branches for default cases, which is the same when checking quasi-enums. The symmetry is nice and makes it very easy to read. But I could be convinced.


What problems did an enum cause you, and how was the enum responsible for the problem?


This is a known issue with Rust, for example. I have an enum with variants A and B. Somebody writes an exhaustive switch (match statement) that handles A and B with no default case. I add a variant C. Their code breaks because they don’t handle C. Adding an enum variant was a breaking change.

In Rust, the answer is #[non_exhaustive], which forces consumers to always add a default case. It’s not a huge deal, just a known issue with a well-understood solution.


I guess I don't quite get this. What I'm seeing here is that a non obvious breaking change is being turned into an obvious one.

If something is handling A and B, but you add C, the code probably needs to make sure it's handling C correctly.

I use Java in my dayjob and the behavior you've outlined is how I always code things, but it's manual and doesn't happen at compile time: I provide default that throws a runtime exception.


If your package updates and users of your package update and their code ceases to compile, that seems... fine? It's the system working as intended. They can just downgrade back to the previous known good version. It would be much worse if you made a breaking change to code but consumers' code that used yours continued to compile but no longer functioned as expected


IME the problem is the default behavior. Rust, Java, et al have that same default behavior of defaulting to closed enums, and you have to opt-in to open enums. Whether that's adding a type attribute in Rust or implementing special code in Java to handle the case. This is a footgun for distributed systems. If you don't get it wrong, then clients writing their own client-side software will get it wrong.

I'm not disparaging closed enums, they are very useful in certain contexts, but they make it really easy to do the wrong thing when reading data off the wire. Given Go is focused on this exact domain (distributed systems), I am glad the language doesn't have them.


I don't think you would need a 2.0 (backward-incompatible language change) for any of this.


Exhaustive switch seems likely to be backward incompatible if done well.

What you want here is something akin to Rust's match behaviour on enumerated types. If your alternatives aren't exhaustive, it doesn't compile. Now, Rust is doing that because match is an expression. Your day matching expression needs a value if this is a Thursday, so not handling Thursday in your day matching expression is nonsense - even though often the value of a match isn't used and might be the empty tuple it necessarily must *have a value.

It seems to me that today a Go Switch statement operating on days of the week can omit Thursday and compile just fine. Exhaustive switch means that's a compile error. If your "exhaustive switch" is optional or just emits a warning, it won't catch many of the problems for which exhaustive switch is the appropriate antidote.


> Exhaustive switch seems

This would only make sense on an enum type, which would be a completely new thing, so it can be introduced without breaking backward compatibility. Constants and switch on non-enum values would stay, because they are useful independent of enum types.


Also makes sense on numbers and other patterns where you can logically enforce exhaustiveness.


Yep, agreed. Maybe a new hypothetical `exhaustiveswitch` keyword could be added that would be backwards compatible, but it doesn't seem very Go-like to have such similar functionality as separate keywords.


I believe new keywords are not backwards-compatible as they will invalidate any code using that as an identifier.


Sum types overlap a lot with what interfaces over. So enhancing the language with proper sum-types would benefit from enhancing interfaces and switch statements.

However, zero values throw a wrench into this. The zero value of an interface is nil, so enhancing interfaces would require you to address what happens with an uninitialized variable. One of the current proposals suggests that nil continue as the zero value.

They could introduce a totally different type, like a sealed interface, which doesn't require a zero value, but that distinguishes between different types of interfaces, and I'm not sure how that'll be received.


> We use enums heavily to force devs who use our code into good choices

Beware, tho, that with many languages today you’re not really doing that even when they advertise enums e.g. in both C# and C++, enums are not type-safe (not even `enum class`). Iota is, at least, a fair acknowledgement of that.

> with a FromString() function

That seems like way a step too far, is there any such “default method” today? And I don’t think Go has any sort of return-type overloading does it?


Really not sure what you're talking about. If you use enums in a real language like Swift, Kotlin, Rust, etc., you can only construct the values of the enum. There are no ways to get around it.


> Really not sure what you're talking about.

Have you considered reading?


Have you considered elaborating? I am likewise unsure how a language could get enums wrong, even after reading your post.


> I am likewise unsure how a language could get enums wrong, even after reading your post.

By allowing any value of the underlying type, even if they were not values of the enum. The language most famous for this is obviously C:

    enum foo { A, B, C };

    int main() {
        enum foo x = A;
        printf("%d\n", x);
        enum foo y = 42;
        printf("%d\n", y);
    }
This will print `0` and `42` (https://godbolt.org/z/Yq8qq5bzW), because C considers enums to be integral types and will thus implicitly convert to or from them. And as you can see from the link, there is no warning under `-Wall`. Clang will warn with `-Wassign-enum` (which is included in `-Weverything`), I don't think there's any way to make gcc complain about this.

Now you might argue that this is C so obviously it fucks this up, however that C does this leads to further issues:

- For compatibility with C, C# enums have the same property. I don't know about you, but that surprised me a great deal since Java has type-safe enums (even if they're not sum types).

- Even more so, C++ added `enum class` in C++11, and obviously has inherited C's enums, but enum class still is not type safe, afaik the differences are that `enum class` is scoped (so in the snippet above you'd need `foo::A`) and it's not implicitly convertible with the underlying type. But it's still explicitly convertible (via a cast or list initialisation), meaning you can't assume a caller will remain within the enum.


This is only a problem in languages that don't support checked type safe enums / discriminated unions, but this whole thread was started to request that feature. I don't get the point of your comments unless it's to state "be careful or they'll do it wrong"... which is obvious and already acknowledged several times in this thread.


Friend - this is a learning opportunity. Check the upvotes.


> Check the upvotes.

Check the upvotes on my comment.


Every time I see discussion about go and enums there are people who are referencing these mythical C-like enums that had never existed. It's some sort of constructed memory. And I'm sure there are languages that do enums "properly", but it's always C/C++ that is referenced.


Ada does them well. They have attributes that allow for converting to and from integers and strings [1], and case statements have to be exhaustive or use a default "others" clause.

[1] https://en.wikibooks.org/wiki/Ada_Programming/Types/Enumerat...


The point here has little to do with Go, which you can see by the quote it replies to having nothing to do with go:

> We use enums heavily to force devs who use our code into good choices

the note is that this is very much language-dependent and there are languages which get it very, very wrong.

> And I'm sure there are languages that do enums "properly", but it's always C/C++ that is referenced.

That makes no sense, the original comment obviously assumes a language which does it "properly".


Sorry, my comment wasn't trying to address (let alone bash) the original comment in any way here, just a tangent based on your note about C# enums.


The type sets proposal for Go has already been accepted as part of the generics proposal [0]:

    type SignedInteger interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
    }
Interfaces that contain type sets are only allowed to be used in generic constraints. However, a future extension might permit the use of type sets in regular interface types:

> We have proposed that constraints can embed some additional elements. With this proposal, any interface type that embeds anything other than an interface type can only be used as a constraint or as an embedded element in another constraint. A natural next step would be to permit using interface types that embed any type, or that embed these new elements, as an ordinary type, not just as a constraint.

> We are not proposing that today. But the rules for type sets and methods set above describe how they would behave. Any type that is an element of the type set could be assigned to such an interface type. A value of such an interface type would permit calling any member of the corresponding method set.

> This would permit a version of what other languages call sum types or union types. It would be a Go interface type to which only specific types could be assigned. Such an interface type could still take the value nil, of course, so it would not be quite the same as a typical sum type.

> In any case, this is something to consider in a future proposal, not this one.

This along with exhaustive type switches would bring Go something close to the sum types of Rust and Swift.

[0]: https://github.com/golang/go/issues/45346


Another possibility is to use boolean flags. Of cause the compiler then will not enforce that only one of the flags is set. On the other hand on few occasions I observed how initial design with disjoint cases evolved into cases that can be set at the same time modeled with flags.


Or even better, proper sum types. They're a superset of enums anyway.

https://github.com/BurntSushi/go-sumtype is great, but a bit unwieldy. Language support would be much better.


Proper compile time sum types would be great. I find myself reimplementing sum types at runtime far too often, especially when it comes to parsing JSON. My go to is a function with a signature along the lines of the reflect packages dynamic select:

func UnmarshalOneOf(data []byte, args []interface{}) (index int, err error)

Which I use like this:

variants := []interface{}{ &T1{}, &T2{}}

i, err := UnmarshalOneOf(data, variants)

// …

return variants[i]


> no exhaustive `switch`

This is what linters will do for you by default.

> we instead need to create ValidEnumValues and remember to update it every time a new enum value is added

Code generators are first class citizens in go, and writing an icky but reliable test won't be too hard either.


> Use int-type enums with iota: […] no compile-time guard against illegal enum values

Create a new int type and use that for your enums. While you still can create an illegal enum value, you basically have to be looking for trouble. It’s not going to happen accidentally. It’s even harder if it’s an unpunished type in a different package.

See:

https://github.com/donatj/sqlread/blob/91b4f07370d12d697d18a...


This compiles:

  type Test int

  const (
    T1 Test = 0
    T2      = 1
  )

  func TestSomething(t Test) {}

  ...
  
  TestSomething(17)
So this isn't a good suggestion, because you can easily pass any int value and will not get a compiler error. You may as well be using strings at that point.


I generally tend to use enumer[0] to generate some boilerplate code that can help with addressing this, e.g. the below would compile, but would error at runtime. There are probably linters out there that could catch this. With Go, linters are generally pretty good at catching this kind of stuff.

    package main
    
    import "fmt"
    
    type Test int
    
    const (
     T1 Test = 0
     T2      = 1
    )
    
    func main() {
     t, err := TestString("T1")
     if err != nil {
      panic(err)
     }
    
     TestSomething(t)
    }
    
    func TestSomething(t Test) {
     fmt.Println(t.String())
    }
Having said that, it seems weird to have to mimic enums, as opposed to actually having it. Doesn't feel like it would add much complexity, if at all.

[0] https://github.com/dmarkham/enumer


> as opposed to actually having it

Like C or C++ do? :)


Right. However, C and C++ are far more complex, with no memory safety. Everything has it's ups and downs.


Your point is valid, but the Go philosophy depends on you following conventions to have reliable code. This is true all over the place, e.g. you can easily ignore errors.

Other languages take a stricter approach, and maybe that's better. Not defending (although I like Go), but it's really more a language philosophy than a singular defect.

As the other commenter noted, this should fail code review and you should be using the provided constants, and it should be clear to you. And if you disagree (which again is totally valid), you should use a stricter language—there's plenty out there!


Fwiw a literal 17 in a function call, let alone anywhere outside an equation or constant definition is a code smell that should never make it past review.

I see your point however.


Depending on code review instead of a static type system does not scale. Look at all of the memory safety security vulnerabilities that are solved by "simply making sure to manage memory correctly."

Also:

  variableDefinedInAFarAwayModule := 17

  ...

  TestSomething(variableDefinedInAFarAwayModule)
  
It's not always as clear as a constant value being passed to an incorrect type.


I don't understand your example here, that's not going to compile.

variableDefinedInAFarAwayModule is definitionally type int and will not be cast. It is also unpublished, so you couldn't be using it for a faraway module?

Your 17 in the previous example has it's typed determined at compile time which is why it can be a problem.

see: https://go.dev/play/p/jEdAhKDeLy6


Ah thank you. That's slightly better then.


This will not compile because the type of the variable is int not Test.


I mean, that's a pretty common usage of enums, isn't it?

    TestSomething(T1 & T2)


And this is the big, probably irreconcilable, difference in culture between the sum-typers and the compiler-assisted-named-valuers....


As amw-zero pointed out though, a user could accidentally create the int type and it would only fail if you have runtime checking (which requires you to build it, either via a custom enum constructor that returns `error` or an `IsValid` function, which then require you to maintain the ValidEnums list).

Int types also don't give you any guards when deserializing.


One caveat here is serialization. Writing your (or another package's) enum to a database will get you in trouble if you ever want to add another value in the middle. Sure, you can be careful and should document this, but who knows


Not only that, but the person sending you the serialized object might be looking for trouble. Sending you an enum value that is outside the legal range might help an attacker get into your system.




Consider applying for YC's Fall 2025 batch! Applications are open till Aug 4

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: