[HN Gopher] Compile-time safety for enumerations in Go
       ___________________________________________________________________
        
       Compile-time safety for enumerations in Go
        
       Author : varankinv
       Score  : 49 points
       Date   : 2023-09-29 12:43 UTC (10 hours ago)
        
 (HTM) web link (vladimir.varank.in)
 (TXT) w3m dump (vladimir.varank.in)
        
       | kleton wrote:
       | I use this in my projects https://github.com/abice/go-enum
        
       | maxekman wrote:
       | Here is an issue tracking a possible fix to this:
       | https://github.com/golang/go/issues/19412
        
       | hqudsi wrote:
       | I sometimes do this but idk if I would consider it 'elegant'
       | 
       | The other 'gotcha' is that in switch statements the compiler
       | can't tell whether you enumerated on all your cases as there is
       | no true enum type so it's not uncommon to have a catch all
       | default case that either returns an error or panics and hope you
       | can catch it during tests if you missed a case.
       | 
       | I just wish go had proper sum types.
        
         | orbz wrote:
         | This is an analyzer that will catch this:
         | https://github.com/nishanths/exhaustive
         | 
         | I believe it's in golangci-lint.
        
       | atombender wrote:
       | The main downside to this approach is that you'll still have to
       | deal with the zero value, which for interfaces is going to be
       | nil.
        
         | Groxx wrote:
         | While I agree nils are a problem with this: as long as they
         | have access to the type at all, they can create zero values.
         | E.g.                 var c color.Color
         | 
         | That's a valid color whether it's a nil interface value or an
         | empty typed string.
         | 
         | You can return a private type to prevent this, but that also
         | means they can't refer to it as an argument or return value
         | anywhere outside the implementing package, which is a rather
         | severe limit in many cases.
         | 
         | Sometimes* I really miss constructors.
         | 
         | *: all the time
        
         | masklinn wrote:
         | No matter how Go solves the issue (assuming it ever does), that
         | will be part of it: unless it goes through a revolution and
         | strips out and forgets about ubiquitous default values (which I
         | don't think it will, C# has barely just dipped its toes into
         | that pool) any sort of sum-type-like construct will need a
         | default value. And I'm not sure `nil` (a clearly corrupted /
         | missing value) is any worse than picking an actual valid value
         | the way non-pointer types do.
        
       | jchw wrote:
       | I dunno if I'd consider having a dummy method on an interface as
       | "elegant", but it does work. A trade-off of keeping the language
       | very simple, for sure.
       | 
       | What I really wish Go had was sum types, Rust style. That'd cover
       | enumerations and more.
        
         | jjice wrote:
         | I go back and forth on this. On one hand, I _love_ Rust/ML
         | style sum types. They're such a fantastic feature. On the other
         | hand, I wonder how much use they'd get in a language like Go.
         | If it's introduced, would it radically change the way some
         | problems get solved? Is it that different than an traditional
         | enum (which Go also skirts around)?
         | 
         | Pattern matching and exhaustive checks are massive benefits of
         | them though. I guess I'm just oddly conflicted here.
         | 
         | I do think that long term, sum types are going to become more
         | prevalent. I'm excited to see it, I'm just interested in how
         | that adoption occurs in existing languages without them.
        
           | Zach_the_Lizard wrote:
           | Sum types with pattern matching would likely take the place
           | of (foo, error) in a lot of codebases.
           | 
           | This is especially true in cases where a function returns a
           | collection of results where each result is independent from
           | other results and a result can succeed or fail.
           | 
           | For instance, a batch report generator that is called every
           | $INTERVAL might return a ([]Report metadata, error) today,
           | but each report could have succeeded or failed without
           | impacting the others.
           | 
           | The output is emailed to $SOMEONE to let them know the
           | reports ran and information about each.
           | 
           | In today's world, the "error" could be a special error type
           | that capture failures on a per report basis. The
           | ReportMetadata type could also have an Error field, but one
           | is not forced to check either.
           | 
           | Sum types could force checking for error on a per report
           | basis, increasing the odds something is done with the error.
        
           | jchw wrote:
           | If I had sum types in Go, I'd use them for state machines and
           | enumerations mostly. I can't see a huge downside; I'm sure
           | some software has little use for it, but it's useful enough
           | for data modelling that protobuf has the sort-of similar
           | oneof primitive. Hmm. On that note, maybe it would be better
           | than nothing to do some kind of code generation here...
        
           | jerf wrote:
           | I think the answer to your "back and forth" is probably laid
           | out in: https://jerf.org/iri/post/2960/
           | 
           | Sum types are useful, even very useful in the right place,
           | but I do think there's a lot of people who use them a couple
           | of times, probably in one of those "right places" and then
           | mistakenly label them in their mind as "better". Just,
           | universally, Platonically "better". They aren't in fact
           | "better"; they're a tool. Sometimes they're the right tool
           | for the job, but much, _much_ more often, they 're just a
           | tool that works, and so do several other tools. People who
           | get too excited about sum types need to be sure they square
           | their understanding of how useful they are with the fact that
           | the vast majority of programs are written without them.
        
             | jchw wrote:
             | This article seems almost entirely concerned with code
             | organization, but in my case I basically never do
             | functional programming and am only concerned about data
             | modelling and data structures. In an adjacent, comment I do
             | acknowledge one point (probably not all software really
             | needs sum types) but I still feel as though languages
             | without sum types are missing an important data and state
             | modelling tool. Trying to take something like a state
             | machine and jam it into interfaces or polymorphism kind of
             | sucks; you lose exhaustiveness checking obviously, but also
             | it enforces a lot of constraints about control flow and
             | data. In Go parlance, if each of my states has a method
             | with its receiver set to the state type, then I can _only_
             | access that data, so for example I 'd need to pass
             | something in or out to handle state transitions. Not ideal
             | IMO.
             | 
             | I feel this pain basically any time I hand-write lexical
             | scanners/parsers in Go or even C++.
        
               | masklinn wrote:
               | > I still feel as though languages without sum types are
               | missing an important data and state modelling tool.
               | 
               | That's because they are. jerf subtly shifts the point to
               | one they can criticise, but it does not change the basic
               | fact that sum types are a critical tool which is just...
               | missing.
               | 
               | There's probably no tool which can't be misused, even the
               | humble boolean, that's not an issue with the tool, and
               | pointing that out is at best irrelevant. It does not
               | change the fact of the matter: you're missing a critical
               | axis of composition. It's like saying screwdrivers are
               | useful but you can misuse them to hammer nails as if that
               | justified trying to screw with a hammer.
        
               | jerf wrote:
               | Where I'd disagree is that they are _a_ tool, not a
               | _critical_ tool, and they aren 't _missing_ from Go, they
               | are simply not the preferred choice. You can cover 75% of
               | the use cases with this approach. It is not 100% of the
               | use cases. But there isn 't a language where you can
               | cover 100% of the use cases with 100% effectiveness,
               | which is why we don't have and never will have The One
               | True Language.
               | 
               | Moreover, Go seems to attract this sort of criticism as
               | if Go is Uniquely Broken and it's nirvana in all the
               | other languages... but I've used enough of them to know
               | better. Sum types are great, until you hit the branch of
               | the expression problem where you really need the other
               | side, and if you're in a language that favors them,
               | you're going to get the same 75% experience, just mirror
               | imaged.
        
               | jerf wrote:
               | "In Go parlance, if each of my states has a method with
               | its receiver set to the state type, then I can _only_
               | access that data, so for example I 'd need to pass
               | something in or out to handle state transitions."
               | 
               | I am not clear what you mean by that, exactly, but
               | generally I model a state machine as a type that composes
               | a state:                   type State interface {
               | isState()         }              // a whole bunch of
               | state types here              type StateMachine struct {
               | State // exported or not exported, depending on local
               | needs              // additional data         }
               | func (sm *StateMachine) Event1(args...) error {
               | // can call current state and change it here         }
               | 
               | You can add an "Execute" method on to the State and have
               | it return an entire state if that's how you want to do
               | it. The state machine can pass in any cross-state data.
               | There's a number of options. You aren't obligated to do
               | exactly, only, and precisely what you'd do in another
               | language, and program X in Y. For some reason, that's
               | common wisdom in the programming world... _except_ for
               | functional programming. I say  "don't program X in Y" is
               | simply true and there is no carveout for functional
               | programming in non-FP languages any more than there is
               | the other way around for OO in FP languages.
        
         | jerf wrote:
         | If you are satisfied with a dummy method on an interface, you
         | can continue by adding more types to that interface. Color is
         | not a great example, so:                   type Vehicle
         | interface {              isVehicle()         }
         | type Car struct {}         func (c Car) isVehicle() {}
         | type Van struct {}         func (v Van) isVehicle() {}
         | func VehicleType(vehicle Vehicle) {             switch v :=
         | vehicle.(type) {             case Car:
         | fmt.Println("car")             case Van:
         | fmt.Println("van")             default:
         | fmt.Println("unknown vehicle")         }
         | 
         | This covers most, but not all, of the bases, in that you don't
         | get exhaustiveness checking at compile time, unless you adjoin
         | a linter to your compile process:
         | https://github.com/BurntSushi/go-sumtype
        
           | jchw wrote:
           | Yeah, I've done that before. It's not really elegant in my
           | opinion, but OTOH, it's basically the best you can do. Oh
           | well.
        
       | the_gipsy wrote:
       | You need the JSON de/encode implementation with checks. At that
       | point you'll want code generation, and something that was never
       | elegant is turning awful.
        
       | SPBS wrote:
       | This is not elegant. It also has overhead. Stick with the simple
       | `const Red Color = "red"`, you're not gaining a lot by doing all
       | these strange type contortions in Go. Seriously consider what you
       | are protecting against, and whether it's an imagined bogeyman.
       | 
       | "Oh but someone may try to cast arbitrary values to my package
       | type" Okay but who is that going to hurt? You or them? Will they
       | get hit with errors early on in the development lifecycle if they
       | do something silly like this? Are you actually going through all
       | these lengths for nothing?
        
         | the_gipsy wrote:
         | It's stuff that just creeps in. Enum values can come from
         | outside (json, anything non-literal). Now you have to do
         | validation, because the bad values parse and for sure exist at
         | some stage in your program.
         | 
         | It's not a bogeyman.
        
         | ollien wrote:
         | What overhead is there at runtime?
        
           | kgeist wrote:
           | Casting a value to an interface forces it to be allocated on
           | the heap.
        
           | Groxx wrote:
           | Interfaces are both allocations and function-call
           | indirection, and I _think_ they also always move their data
           | to the heap.
           | 
           | Which is almost always trivial except when it isn't.
        
       | euroderf wrote:
       | In a more dynamic environment where you could manage values in a
       | controlled manner, how could you validate values ?
       | 
       | One idea is to make a DB table dedicated to enumerations and then
       | use foreign key constraints. Define a unique namespace for each
       | set of enumerated values, and catch foreign key constraint
       | failures on DB writes.
        
       | earthboundkid wrote:
       | I would be interested to read bug reports from people who thought
       | they had a complete enumeration but learned in production that
       | their enumeration was incomplete. I've never seen it myself.
        
         | Someone wrote:
         | An easy way to get that is when you use a third-party library,
         | update that to get some new feature, and get a new enum value
         | for free, say because the html parser library started
         | supporting a new node type or added a new error type (the
         | latter will be rare in go because of its lack of sum types).
         | 
         | Ideally, of course, the release notes of the library would
         | spell out the issue, and you'd read them, but even then, making
         | sure you fix this everywhere is way easier if the compiler
         | refuses to compile your code if it doesn't handle the new case.
        
           | earthboundkid wrote:
           | Did this actually happen to you or are you just theorizing
           | that it could happen?
        
         | Zach_the_Lizard wrote:
         | This is relatively common with naive int / string to enum type
         | conversions, especially if the int or string are given as input
         | from an RPC mechanism of some sort.
         | 
         | This type of pattern can be used to force other developers to
         | check if the value is garbage before calling $BUSINESS_LOGIC
         | that uses the enum
        
       | kiitos wrote:
       | Unfortunately not.                   func main() {          c :=
       | color.Red          cptr := (*string)(&c)          *cptr =
       | "orange"          PrintColor(c) // successfully compiles, and
       | prints "orange"         }
        
         | revoly wrote:
         | I don't think the solution is trying to cover this. If a user
         | does type conversion then usually they know what they are
         | doing. It's like going out of the way. The solution is to just
         | hide a type behind an interface to be explicit about it and to
         | avoid the error that would have gone unnoticed otherwise.
        
           | kiitos wrote:
           | The article is titled "Compile-time safety for enumerations
           | in Go" but my example demonstrates that this is not the case.
        
             | dgb23 wrote:
             | You demonstrated that you can deliberately circumvent it.
        
             | taeric wrote:
             | The counter point is that this is like arguing that an
             | article about safe knife skills are silly, because you can
             | still just stab yourself?
        
               | kiitos wrote:
               | I'm not sure how that's comparable. Compile-time type
               | safety is a boolean thing, you either have it or you
               | don't. If you have it, then my example shouldn't compile.
               | But it does compile, so you don't have it.
        
               | taeric wrote:
               | I think by insisting to view it only as a boolean thing
               | is very limiting. By that measure, are the type safety
               | hints of Python not really giving any type safety? Just
               | give up and go home, as it were? :D
               | 
               | I'm sympathetic to the points, I think. But I also don't
               | have a problem with someone pushing for some type safety
               | as long as you avoid actively trying to go against it.
        
               | kiitos wrote:
               | > By that measure, are the type safety hints of Python
               | not really giving any type safety?
               | 
               | If we're talking compile-time, then: yes, absolutely!
               | 
               | Type hinting doesn't provide type safety, because type
               | safety isn't a spectrum.
        
             | paulddraper wrote:
             | It is the case under certain conditions.
             | 
             | Casting is by definition an escape hatch.
        
           | throwaway894345 wrote:
           | The type conversion is a red herring. He's just changing the
           | value of a variable by referencing and dereferencing a
           | pointer. In other words, he's not using a pointer conversion
           | to change the value of `color.Red`, he's creating a new
           | variable `c` and assigning `color.Red` to it, and then
           | changing the value of the variable through the pointer and
           | type conversion. But he doesn't even need to do the
           | pointer/type conversion stuff, he could just do `c =
           | Color("orange")` and call it a day.
        
             | kiitos wrote:
             | > But he doesn't even need to do the pointer/type
             | conversion stuff, he could just do `c = Color("orange")`
             | and call it a day.
             | 
             | c = Color("orange") does not compile.
        
               | throwaway894345 wrote:
               | Ah, I misunderstood what type color.Red had. My mistake.
        
         | avg_dev wrote:
         | i appreciate this counter-example; thanks. i was hoping there
         | was a way to get real enums in go.
        
         | throwaway894345 wrote:
         | You're not changing the underlying value of `color.Red`, you're
         | changing the variable (in other words, `PrintColor(color.Red)`
         | will still print `red`). If that's the point you're intending
         | to make, then couldn't you make it more easily with this?:
         | c := color.Red         c = Color("orange")
         | PrintColor(c)
        
           | masklinn wrote:
           | > You're not changing the underlying value of `color.Red`
           | 
           | Doesn't matter, the point is they're getting in a value which
           | is not part of the defined set, demonstrating that this
           | emulation is not actually typed-safe.
           | 
           | > If that's the point you're intending to make, then couldn't
           | you make it more easily with this?:                   cannot
           | convert "orange" (untyped string constant) to type Color
        
         | tedunangst wrote:
         | At some point I think it's worth asking who are we trying to
         | stop and why.
        
           | kiitos wrote:
           | Indeed. Go types are like simple locks: they keep honest
           | users honest, but they don't prevent dishonest users from
           | breaking your assumptions. The point is just that "compile-
           | time type safety for enumerations" isn't actually true, or
           | even possible. You always have to do some amount of runtime
           | validation of received values.
        
             | chabad360 wrote:
             | No, you don't. If your module relies on compile time checks
             | for validity, there's no reason for you to also include
             | runtime checks. Other developers breaking your defined
             | contract is not your responsibility, it's not even a bug.
        
               | kiitos wrote:
               | > If your module relies on compile time checks for
               | validity,
               | 
               | ...then it's broken, because the Go compiler, factually,
               | cannot (in general) check or enforce the validity of
               | specific values.
               | 
               | > there's no reason for you to also include runtime
               | checks. Other developers breaking your defined contract
               | is not your responsibility, it's not even a bug.
               | 
               | It is absolutely the responsibility of the function
               | func PrintColor(c Color)
               | 
               | to ensure, at runtime, that `c` is a valid Color. My
               | example code -- which allowed an invalid Color value of
               | "orange" -- is absolutely a bug in PrintColor.
        
               | gabereiser wrote:
               | It's actually not and is one of those Java isms that have
               | made its way into generalized software engineering.
               | 
               | If I write a function that takes Foo as an argument. I
               | have a Foo implementation exposed elsewhere in my
               | program. It is absolutely expected that I mean MY Foo,
               | not your Foo. If you pass me your Foo, you will get
               | unexpected results that are not a bug, not a side effect,
               | not my responsibility. It's yours, the caller, to adhere
               | to the contract.
               | 
               | There are valid reasons to not adhere. To pass your own
               | implementation. At that point, you are responsible for
               | its use. Not me. If it adheres to MY Foo's interface, you
               | might get by, but it's is not my responsibility to
               | validate all permutations of unknown types to ensure
               | you're passing me mine. In go, it's perfectly valid to
               | return structs but accept interfaces as arguments so that
               | you, the program author calling my api, can craft the
               | correct program flow.
               | 
               | So please, leave that runtime type check reflection for
               | Java and C# and the land of JS. We have no need for it
               | here in machine code land.
        
               | ori_b wrote:
               | Exactly. Someone could always connect to your program
               | with a debugger and modify the instructions are being
               | run, and that's why you need to regular runtime checks of
               | the validity of your program text. If you want to handle
               | people actively trying to undermine the assumptions your
               | program is making, you can't leave out easy to replicate
               | cases like 'gdb attach', obvious, non-accidental use of
               | the type system, and other similar categories of issues.
               | 
               | What strategies do you use in your programs to ensure
               | type safety in the event of debugging, mmap, and other
               | mechanisms that can be used to subvert the memory layouts
               | your compiler thinks it produced?
        
               | athrun wrote:
               | That doesn't make any sense to me.
               | 
               | Most instructions are not atomic, so even if you had
               | runtime checks something like a debugger could still
               | inject invalid state in between your checks, making them
               | ineffective.
               | 
               | If real-time code injection is your threat model, I don't
               | see how runtime checks would get you anywhere.
        
               | wahern wrote:
               | You seem to be suggesting that (to use a common phrase)
               | if something can be done, you should do something. The
               | previous poster's example regarding a debugger was to
               | challenge the notion that there was any meaningful reason
               | to constrain non-accidental type subversion. You seem to
               | be saying it's absurd to care about someone using a
               | debugger to inject invalid code at runtime _because_
               | there 's nothing you can do about it, thus implying if
               | you can do something, you should. But that only begs the
               | question--why _should_ you? To some people, defending
               | against debugger code injection is no more absurd than
               | defending against other examples where the programmer
               | deliberately alters code to subvert static type checking.
               | If you disagree then articulating more meaningful
               | distinctions among cases would be worthwhile. Mere
               | ability is generally not considered sufficient
               | justification alone to do something.
        
       | davidkunz wrote:
       | > Go's type system allows preventing both issues in a rather
       | elegant way.
       | 
       | Proper enums would be elegant, not this.
        
         | masklinn wrote:
         | Some way to define a closed set of values anyway. It doesn't
         | even need to be classic-style sum types, for instance as
         | support for generics Go introduced support for union types (I
         | don't think they have a name?) e.g.                   type Foo
         | interface { A | B | C }
         | 
         | and the interface type is the union of those type-sets. Such
         | interfaces can not currently be used outside of type
         | constraints, but if that is relaxed, and type switches are
         | updated to support and enforce exhaustive matching (and
         | understand such sealed / nominative interfaces), you've got all
         | the bits you need.
         | 
         | You'd need to newtype variants to add payloads of similar
         | underlying type e.g. `int | int`, but that's not a huge
         | imposition, and the variants being types themselves is often
         | convenient so it's a 50:50 tradeoff compared to classic sum
         | type (where constructors disambiguate all variants but are not
         | themselves types).
        
           | foldr wrote:
           | Agreed. A slight wart is that default initialization would
           | require either boxing or a somewhat arbitrary decision about
           | which variant should be the default. So if you have e.g.
           | var foo interface{ A | B }
           | 
           | then foo either has to be boxed (so the default value is a
           | nil interface) or unboxed and arbitrarily initialized as an A
           | or a B.
        
             | masklinn wrote:
             | To me it would make sense that this be implemented as a
             | normal interface at least initially, it would reuse all the
             | existing bits of the language as is. Note that whether an
             | interface boxes depends on escaping.
             | 
             | I don't see how unboxing would have to be "arbitrarily
             | initialized as an A or a B" either. Even with a novel
             | bespoke implementation you still need a discriminant
             | between the two nested. You could keep a nil default by
             | reserving the zero discriminant for that purpose.
        
               | foldr wrote:
               | I agree with your first paragraph.
               | 
               | I think it would be weird if an unboxed union of A and B
               | was initialized to a value that wasn't the default value
               | of either A or B, although I take your point that it's a
               | technically possible implementation.
        
               | masklinn wrote:
               | > I don't quite understand the second one (is 'boxing'
               | supposed to be 'unboxing'?)
               | 
               | Yep, fixed, sorry about that.
               | 
               | > I think it would be weird if an unboxed union of A and
               | B was initialized to a value that wasn't the default
               | value of either A or B
               | 
               | On the one hand yes, on the other hand it would be
               | consistent with the langage semantics (the default value
               | of an interface type is a nil), and I don't think
               | defaulting to the default value of an arbitrary variant
               | is better.
               | 
               | Although I can see the similarity with iota / newtype
               | constants, the first item being the default, and
               | similarly people could set the first type as relevantly
               | named (possibly unexported) empty struct if they want /
               | need the signal.
        
       | pjmlp wrote:
       | Pascal like enumerations, yet another feature that Go missed from
       | the 1970's.
        
       | dgb23 wrote:
       | Clever!
       | 
       | But what problem does this actually solve?
       | 
       | When I see a type alias of a string and enumerated variants then
       | why would I pass in arbitrary strings?
       | 
       | There are _always_ ways to break assumptions. Putting arbitrary,
       | complex guard rails at every call site doesn't make a program
       | magically better.
       | 
       | An API is primarily about affordances. We provide utility to the
       | caller.
        
       ___________________________________________________________________
       (page generated 2023-09-29 23:01 UTC)