Hacker News new | past | comments | ask | show | jobs | submit login
Compile-time safety for enumerations in Go (vladimir.varank.in)
66 points by varankinv 11 months ago | hide | past | favorite | 113 comments



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.


This is an analyzer that will catch this: https://github.com/nishanths/exhaustive

I believe it's in golangci-lint.


>I just wish go had proper sum types.

It's by far my favorite feature of Swift.

Enums + Payloads + switches are incredibly simple yet so effective. You can make a simple state object, isolate it with an actor, then stream it anywhere with Combine (or now Observability). You'll get full compiler safety too, forcing any new state to be handled everywhere.

You can even stack that with _generic_ payloads that conform to protocols, and if you're feel brave you can make those payloads equable completions for _typed returns_.

for example (I hate formatting on here, but I'll give it a shot)

// A user state enum that conforms to loggable.

// The state is generic, any object T that conforms to "Partner" protocol can be a partner

enum UserState<T: Partner>: Loggable {

   // Your states
   case loggedOut(error: String?)
   case loggingIn(email: Email)   // Email type is a string that's been validated
   case loggedIn(authToken: String, user: User, partner: T)
   case loggingOut(exampleBlock: (String) -> Bool) // You can embed a callback here
   

   // Utility Functions

   /// Is the user logged in?
   func isLoggedIn() -> Bool {
      switch self { 
         case .loggedOut, .loggingIn: return false
         case .loggedIn: return true
      }
   } 

   /// Loggable conformance
   func logText() -> String {
    // switch that converts state into something that's safe for your logging service
   }
}

In the above, any subscriber that gets a UserState object can switch on it, and for example if you're logged in you 100% get the auth token, user, etc. It's impossible to be logged in without those in-context.

It might look something like this in your UI:

/// Called by a subscriber that's hooked in to the state stream

func onUserStateChanged(newState: UserState) {

   switch newState {

      case let .loggedOut:
      // Impossible? Error UI

      case let .loggingIn, .loggingOut:
      // loading UI

      case let .loggedIn(authToken: _, user: user, parner: partner):
      // set some text "hello \(user.userName)"
      // display an image: \(MyRemoteLoader.url(url: partner.primaryLogoURL))
      // Button to website: Button(url: partner.homePageURL)
   }
}

add a new state later? The compiler will error in the 500 places in your codebase you didn't handle the new case with one build command.


Love this. Not only is it my favorite feature of swift, but I often say swift enums are my favorite feature of any programming language. They are just so elegant and powerful


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.


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


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.


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.


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.


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++.


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


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.


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

Which other side? Product types (aka structs)? I don't think there are any languages with Sum types that don't also include Product types.


"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.


I guess what I don’t understand about this is sum types represent a state that is so ubiquitous in our lives that it’s hard for me to go a day without seeing one. It’s simply the state of something either is this or that or that. It would be like a language without records. It’s missing a part of reality that shows up all the time


I like how you start the article by deriding this idea of using sum types dogmatically because “FP is Better” (the dogmatic capital-case “Better”, even) and then close the article with a therapeutic angle about making do with what you are given.

> I don't really care if you do or do not stay angry at the language you're forced to work in for your job. What I do want for you is to be able to make the best of the situation you're in and not be unhappy.

Because maybe enumerations in Go are subpar compared to other languages (FP or not).


It's not the tool that's better; it's having the tool that's better.


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.


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


I hope Go will eventually get sum types but I hope they're better than Rust's where each variant is its own type.


How is this mess better than Rust's enums? A Rust enum + match statement results in really easy to read and clean code.


For a trivial example, let's say I have some message type that has multiple variants with differing fields:

    enum Message {
        Action1(String, Option<Mode>),
        Action2(String),
    }
Action1 and Action2 are variants not types and I cannot make functions that take an Action1 or an Action2 as a parameter. To get around this people will often make each enum variant just a container for some struct with the same name but that's more verbose and matching becomes slightly uglier. Code like this is fairly common:

    struct Action1(String, Option<Mode>);

    struct Action2(String);

    enum Message {
        Action1(Action1),
        Action2(Action2),
    }
Ideally I'd like to be able to succinctly say something like:

    type A = B | C
and have A be dependent on B and C but have both B and C be completely independent types.


That is a pretty compelling point... I ran into this less than an hour ago and was slightly frustrated.

Though, I still like the flexibility of adding additional fields to `Action1`. In golang I end up with fields that are conditionally populated based on the state of an Enum which is less than ideal and leads to lots of error checking (though this is relatively rare).

It doesn't have to be one or the other though. A bit more polishing on the Rust-style enums (perhaps in a different language) could lead to pretty ergonomic code.


> Rust's where each variant is its own type.

Is this... right? You can't use enum variants as types in function signatures, variable declarations, etc.


Unfortunately not.

    func main() {
     c := color.Red
     cptr := (*string)(&c)
     *cptr = "orange"
     PrintColor(c) // successfully compiles, and prints "orange"
    }


At some point I think it's worth asking who are we trying to stop and why.


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.


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.


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


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?


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.


You seem to be suggesting that (to use a common phrase) if something can be done, it should be done. 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.


This is an inane comparison. Casting as demonstrated in the example is normal and has to be expected. Go types aren't as strict as types in other languages. Code can't assume otherwise.


> Casting as demonstrated in the example is normal and has to be expected.

Can you give an example where someone has done similar casts accidentally? It seems like it would be hard to accidentally typo. That leaves malice, which seems difficult to defend against, in light of the kinds of system calls that are available to a program.


Sure, by parsing a string to a Color value.


The example given wasn't a simple cast of a value, and definitely not an implicit coercion. The example is more like C-style type punning where you explicitly cast a pointer to the value and then write through the dereferenced pointer.

I don't doubt that there are niches where such explicit type coercion patterns are common in Go and susceptible to mistakes, but I doubt usage of constant identifiers is such a niche.

Rust is currently the standard bearer for strong, static type safety, and it even has both the enum types and pattern matching construct which Go lacks. AFAIU, you can use unsafe{} Rust code to perform a similar type punning trick, successfully assigning an invalid value to an enum object. I don't know if Rust's code generator always inserts runtime validity checks in match statements on an enum value without a catchall/default case, but certainly it's possible for Rust code to have an explicit if/else chain that at compile time appears comprehensive but which would neither panic nor produce the expected behavior. Does that mean Rust programmers shouldn't rely on Rust's static typing, instead always adding explicit code to handle unknown/invalid enum values?

Maybe the assertion that Go code should have such checks is more reasonable than for Rust code, but you haven't explained how. At least to me, the simplest, minimal code to achieve the subversion in both Go and Rust seems similarly stilted and similarly unlikely to be written by mistake. (To be clear, the context of this subthread as I understand it assumes the interface method hack, the subversion of which requires the type punning.)


I don't know what point you're trying to make, beyond just objecting to the points that I'm trying to make. Not interested in continuing.


Can you point at a git commit where someone had a similar bug implemented by accident and fixed it? The code you posted earlier seems to be something that would be incredibly hard to write without malice. And if you're considering malice, you also need to consider mmap, foreign function interfaces, writes to /dev/memory, and other similar perverse mechamisms.


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.


> it’s is not my responsibility to validate all permutations of unknown types to ensure you’re passing me mine.

Of course it is! If you provide an API that takes a Color, and you define valid Colors as exclusively Red, Green, and Blue, then you are absolutely responsible for validating input Color values and rejecting anything which is not Red, Green, or Blue. If you delegate that responsibility to the caller, then your API provides no meaningful encapsulation, and isn't a useful abstraction -- it's entirely leaky. No bueno.


‘type Color string’ is just a type alias for string.

Likewise GLint is just a type alias for int. There are only value types (str, int, float, etc), everything else is a construct. The only true types are those value types (and pointers to them). If you call a type a Color and I call a type a Color, you are using my lib to build a program (not me using yours), you must adhere to my contract of what a Color type is to my API. Period. You can not call a function with an unknown type and expect it to behave properly.

If it panics, it’s your fault.


The point here is that, in Go, "what a Color type is" isn't something that can be enforced by the compiler. And more specifically, Go doesn't allow you to define reliable enums.


It is enforced by the compiler, what it doesn't do is guarantee it at runtime. I agree with you on the enums. Go doesn't have a reliable enum construct. But the argument that I, the library author, must validate and check against any possible type of "color" you, the program author, can come up with is just crazy talk. I'll provide a Color type, I'll even pre-define some colors for you, but if you send me a Color that's rainbow, I'll panic.


It literally isn't enforced by the compiler, because I can compile code which is invalid.


It’s not invalid because a string is a string. You didn’t invent a new type.


By the same token, Rust has no type safety because std::mem::transmute exists. In practice, it's not a problem because transmute is not a valid way to construct or interact with values (outside of a few special cases). By calling transmute you're explicitly opting out of all safety and abstraction, and entering here-be-dragons territory.

(That said, the Go syntax looks far less "scary" than `unsafe { transmute::<_, Colour>("orange") }`, which I'd definitely call a design issue.)


The code in my example is an entirely valid way to interact with values, and doesn't represent an "opting out" of safety or abstraction.


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.


The article is titled "Compile-time safety for enumerations in Go" but my example demonstrates that this is not the case.


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?


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.


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.


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


To me it's pretty clear that safety is a spectrum (e.g. we give cars safety ratings), and therefore so is type safety.

Is "type safety" something you saw defined in a PL book or something, which has a more rigid definition to you?


Out of 10 possible errors a compiler can catch 0, 1, 2, 3, etc. up to 10.

A compiler that catches 7 errors is more safe than one that catches 4, and one that catches 4 is more safe than one that catches 0.


You demonstrated that you can deliberately circumvent it.


It is the case under certain conditions.

Casting is by definition an escape hatch.


Casting (in the way I did it in my example) is entirely normal and common and in no way an escape hatch.


Ask yourself: what does casting do?


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.


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


Ah, I misunderstood what type color.Red had. My mistake.


It's the game of the mouse and the cat :-D We can change the package color like this :

  package color

  type Color interface {
    void()
  }

  type color struct {
    v string
  }

  func (c color) void() {}
  func (c color) String() string {
    return c.v
  }

  var (
    Red   color = color{"red"}
    Green color = color{"green"}
    Blue  color = color{"blue"}
  )


    func fn(c color.Color) {
     switch c {
     case color.Red, color.Green, color.Blue:
      log.Printf("c=%#+v -- OK", c)
     default:
      log.Printf("c=%#+v -- should not be possible", c)
     }
    }
    
    func main() {
     var c color.Color
     fn(c)
    }    
Output:

    c=<nil> -- should not be possible
:shrug:


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)


> 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


Yeah, I thought Color was `type Color string`. I misunderstood what he was doing.


i appreciate this counter-example; thanks. i was hoping there was a way to get real enums in go.


In this example, *cptr is a color by contract. A Color is a type alias of a string. *cptr is a string. Interfaces match.


Technically not a type alias, just a normal type declaration. But yes, this is my point: you can't assume that an enum type will always be one of a fixed set of values that you've defined. You always have to do at least a little bit of runtime validation of the parameter. It's unavoidable.


alias and type redefinitions are conceptually the same. int and int_t are conceptually the same. I’m going to assume it’s my type, as defined in the signature of my method you’re calling. If you don’t adhere to the contract then the onus is on you. I’ll check existence, I’ll check conformity, but I won’t check that your string = my string.


> alias and type redefinitions are conceptually the same.

In Go, they are not. Aliases establish precise equivalence between types, type redefinitions establish new types altogether.


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?


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.


Yes it is important to have validation for anything coming in from outside (json, the database, etc). This is done by creating a map with the valid enum values and checking at runtime. This compile time solution wouldn't have worked for validating enums sent in json anyway, it's purely a defense mechanism against library users trying to cast arbitrary values to a package type.


Accidental zero value enums are a constant plague as far as I've seen. Almost any defense is worth it.


Yeah, this is the big thing. Honestly I'm not so worried about people using pkg.fun("red") or pkg.fun(25) instead of pkg.fun(pkg.Red) if they really want to, and in some cases it's IMHO even fine (e.g. HTTP status codes, where everyone knows what 404, 500, etc. mean).

It's pkg.fun(someVar) where someVar is accidentally 0 or "" because it comes from 3 functions away.


zero-values are the root of all go problems. Lack of sum/union/algebraic types are the root if zero-values.


What overhead is there at runtime?


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.


Casting a value to an interface forces it to be allocated on the heap.


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.


> Go’s type system allows preventing both issues in a rather elegant way.

Proper enums would be elegant, not this.


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).


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.


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.


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.


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


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.


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


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.


Another approach could be:

  package color

  type Color struct {
    val string
  }

  func (c Color) String() string {
    return c.val
  }

  var (
    Red   = Color{val: "red"}
    Green = Color{val: "green"}
    Blue  = Color{val: "blue"}
  )
Since `val` is not exported, external packages cannot create arbitrary `Color`.


Ins't it still possible to construct the zero value for Color ? Like how bytes.Buffer does it https://pkg.go.dev/bytes#Buffer : the implementation is not exported but you the usage is to construct the zero value and use it. So unless Color{} is a valid enum value, this solution would not work.


interesting approach! but I typically use this method for type constraints.

for enums I prefer just direct structs: https://github.com/nikolaydubina/go-enum-example


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.


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.


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.


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.


Did this actually happen to you or are you just theorizing that it could happen?


One difficulty with this is, adding a new enum value should therefore be semver-major.


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


At my current day job we have many, many such "enums" that slowly expand over time. I've seen many bugs from new values not being taken into account in the several places one has to remember to double check. Sometimes testing catches these, sometimes it doesn't.


You can implement the interface from other packages through embedding

    type ColorWrapper struct {
        color.Color
        ...
    }
ColorWrapper will implement color.Color. I don't know what the point would be, but another way to break the compile-time type safety.


Here is an issue tracking a possible fix to this: https://github.com/golang/go/issues/19412


This is a more recent proposal, and at this time more likely to get traction: https://github.com/golang/go/issues/57644

As stated in that proposal, the interaction between what people want from “enums,” “discriminated unions,” “sum types,” (as well as “optional values” and “nil safety”) and fundamental Go tenets like zero values, make this a tough sell, which is very likely to make no one happy.


I use this in my projects https://github.com/abice/go-enum


Pascal like enumerations, yet another feature that Go missed from the 1970's.


Go doesn’t have type-safe enums in the language? That’s wild, even C compilers have this feature now (optionally), and then there’s Rust/Swift’s capabilities.


Welcome to Go. “Wait, Go doesnt have X?” I’m convinced its an incomplete language masquerading as a simple one.


Well, that's an opinion. Depending on how you define "complete," any language with fewer features than Scala might be considered incomplete

Unless you mean: is it enough of a language to be useful for its intended usages? In which case my decade of paychecks would like to let you know that it is, in fact, sufficiently complete for many business needs.


This is either skirting close to the Nirvana Fallacy or using unwarranted hyperbole.

Enumerations aren't an advanced language feature at all and Go was released in 2010.


It is one of those languages that I will use if I have to, not because I want to.


They are type-safe, but they are not sum types.




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

Search: