proposal: spec: exhaustive switching for enum type-safety

https://github.com/golang/go/issues/36387

ermik — January 4, 2020 at 10:56 PM UTC

Original ideas and research ### Prior Art In no specific order: - [#32176](https://github.com/golang/go/issues/32176) has shown there is some confusion behind the sense of enum not being a native? type (sugar), but an engineering pattern/trick. - There are plenty of examples of this pattern being _baked_ into the language, some outlined in [#28987](https://github.com/golang/go/issues/28987). (As well as clear argumentation of what needs to improve.) - In fact `case` keyword has already been appropriated for enum declarations in [#28438](https://github.com/golang/go/issues/28438) leading to defacto parity with language like Swift. - While others, e.g. [#28438](https://github.com/golang/go/issues/28438) need enums to be akin structs with dot-notation hierarchy access as if it's a graph. - Lacking _Range type_ sugar is another source of confusion ([#30428](https://github.com/golang/go/issues/30428) and). - Some _range_ issues are really about constraining the enumeration — [#29649](https://github.com/golang/go/issues/29649) - Aliasing possibility ([#17784](https://github.com/golang/go/issues/17784)) for a short time further confused the enum declarations. - Apparently, clarity of Go int enumeration being an ordered list of things is a _feature_, and [#13403](https://github.com/golang/go/issues/13403) seems like it would be an unnecessary change to the `gofmt` status quo. - There is enum chatter which is _really_ about getting Strings out of the enum values ([#32176](https://github.com/golang/go/issues/32176), [#13744](https://github.com/golang/go/issues/13744)). - Interestingly enough the _immutability_ question, which underlies the enumeration pattern, has been discussed in [#27975](https://github.com/golang/go/issues/27975). [@beoran](https://github.com/beoran) cites people saying "go doesn't have enums", when they mean "go doesn't implement conveniences based on enum keyword" - `iota` usage extension has been mentioned in [#21473](https://github.com/golang/go/issues/21473) and I take it as a sign `iota` will not be going away... - The root [#19814](https://github.com/golang/go/issues/19814) is again about keyword introduction to purge the unexpected conditions in a "enum" value switch. The earlier questions [#17205](https://github.com/golang/go/issues/17205), [#9481](https://github.com/golang/go/issues/9481) outline the core needs — how do we ensure only known values are accepted? - Ultimately, [#5469](https://github.com/golang/go/issues/5469) decided not to touch the enum switching, but Go 2 seems like a great time to revisit. - Very early on ([#359](https://github.com/golang/go/issues/359)) the _type-safety_ was the only omission from the const-enum. ### Thoughts 1. `enum` *is indeed* new type, which is what `type State string` does, there is no idiomatic need to introduce a new keyword. Go isn't about saving space in your source code, it is about readability, clarity of purpose. 2. Lack of type safety, confusing the new `string`- or `int`-based types for actual strings/ints is the key hurdle. All enum clauses are declared as `const`, which creates a set of known values compiler can check against. 3. `Stringer` interface is the idiom for representing any type as human-readable text. Without customization, `type ContextKey string` enums this is the string value, and for `iota`-generated enums it's the integer, much like [XHR ReadyState codes](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState) (0 - unsent, 4 - done) in JavaScript. **Rather,** the problem lies with the fallibility of custom `func (k ContextKey) String() string` implementation, which is usually done using a switch that must contain every known enum clause constant. 4. In a language like Swift, there is a notion of _an exhaustive switch_. This is a good approach for both the type checking against a set of `const`s and building an idiomatic way to invoke that check. The `String()` function, being a common necessity, is a great case for implementation. ### Proposal
package main

import (
    "context"
    "strconv"
    "fmt"
    "os"
)

// State is an enum of known system states.
type DeepThoughtState int

// One of known system states.
const (
    Unknown DeepThoughtState = iota
    Init
    Working
    Paused
    ShutDown
)

// String returns a human-readable description of the State.
//
// It switches over const State values and if called on
// variable of type State it will fall through to a default
// system representation of State as a string (string of integer
// will be just digits).
func (s DeepThoughtState) String() string {
    // NEW: Switch only over const values for State
    switch s.(const) {
    case Unknown:
        return fmt.Printf("%d - the state of the system is not yet known", Unknown)
    case Init:
        return fmt.Printf("%d - the system is initializing", Init)
    } // ERR: const switch must be exhaustive; add all cases or `default` clause

    // ERR: no return at the end of the function (switch is not exhaustive)
}

// RegisterState allows changing the state
func RegisterState(ctx context.Context, state string) (interface{}, error) {
    next, err := strconv.ParseInt(state, 10, 32)
    if err != nil {
        return nil, err
    }
    nextState := DeepThoughtState(next)

    fmt.Printf("RegisterState=%s\n", nextState) // naive logging

        // NEW: Check dynamically if variable is a known constant
    if st, ok := nextState.(const); ok {
        // TODO: Persist new state
        return st, nil
    } else {
        return nil, fmt.Errorf("unknown state %d, new state must be one of known integers", nextState)
    }
}

func main() {
    _, err := RegisterState(context.Background(), "42")
    if err != nil {
        fmt.Println("error", err)
        os.Exit(1)
    }
    os.Exit(0)
    return
}
P.S. Associated values in Swift enums are one of my favorite gimmicks. In Go there is no place for them. If you want to have a value next to your enum data — use a strongly typed `struct` wrapping the two.

_Originally posted by @ermik in https://github.com/golang/go/issues/19814#issuecomment-551095842_

mvdan — January 5, 2020 at 8:14 AM UTC

Personally, I deal with this by writing code in a conservative way, like:

type Foo int

const (
    A Foo = iota
    B
    C
)

func F(foo Foo) {
    switch foo {
    case A:
        // code
    case B:
        // code
    default: // C
        // code
    }
}

Then, exhaustive tests and code coverage will catch if I missed any of the cases.

Certainly not perfect, but I've found that this works well. In multiple years of working with iota constants in Go, I don't recall a single time where I've had bugs that could have been avoided via exhasutive switches.

ianlancetaylor — January 6, 2020 at 11:11 PM UTC

If I understand this correctly, the suggestion here is to add two new language constructs. I'm going to try to restate them in words rather than entirely in examples.

First,

    switch x.(const)

Here x must be a value of some type that supports constants. The pcakage that defines the type may have defined const values of that type. The switch statement is required to have a case for each const value of that type.

It's not immediately clear what should happen if the type is exported and other packages define constants of that type. Do we only check for constants defined by the package that defines the type? Should we also check for constants defined in the package where the switch x.(const) appears?

The second new construct is a new kind of type assertion

    x, ok := x.(const)

As with the new switch statement, the language looks for defined const values of the type of x. The type assertion checks that x has one of the defined value, and sets ok accordingly. Presumably if the value of x is not a defined value, the first result is the zero value of the type, and if x is a defined value, the first result is simply x.

The initial result being simply x is somewhat redundant, but I guess it's worth it because of the parallelism with type assertions.

ianlancetaylor — January 6, 2020 at 11:15 PM UTC

I note that one common request for an enum type is a way to iterate through the valid values. That is not supported here.

Another common request for enum types is some sort of requirement that variable of the enum type can only take on valid values. That is only partially supported here, in that code can use the new type assertion syntax to verify that it has a valid value.

I do like the minimalistic approach to ensuring that switch statements are complete, as that is probably the most common request for enum values.

ermik — January 6, 2020 at 11:46 PM UTC

@ianlancetaylor Ian, thank you for jumping in to help me clarify. I don't have enough experience in language development and really appreciate the guardrails.

restate them in words rather than entirely in examples

It was an oversight on my part not to slim it down. But the core goal was to show something that feels neat and quirky yet clear like Go is.

if the type is exported and other packages define constants of that type

One thing I was thinking about is that if const usage would be the gateway to restricting the possible range of values it may be used in the function signature as well. This would allow accepting values from outside the package boundary.

Presumably if the value of x is not a defined value

There is another case I've thought of since I've reposted this as a separate issue:

package inner

type State int

const (
        A State = iota 
        B
        C
)

does not prevent, within or outside the package:

const duplicate State = 1

In such cases, vet may need to warn about typed constant collision, but the new x.(const) assert will have to be the runtime check. I would constrain x to the innermost (innermost package, earliest definition) constant value — B = 1, effectively performing the task of eliminating typed constant collision vet was suggesting, but we could also disable the culprit package enumeration, setting ok to false due to ambiguity.

I note that one common request for an enum type is a way to iterate through the valid values. That is not supported here.

In accordance with long-standing conversation about the lack of mapping functions, one would write the for loop themselves. range Type is another possibility further reusing existing syntax, look, and feel.

Another common request for enum types is some sort of requirement that variable of the enum type can only take on valid values. That is only partially supported here, in that code can use the new type assertion syntax to verify that it has a valid value.

I guess I would say that core purpose of this proposal is to introduce an incremental change which enables vet to warn about these issues with a clear resolution strategy (exhaustive const switch, const-assert, pruning typed constant collision), rather than abstracting them into the language itself by introducing new keywords and syntactic sugar.

Would love to talk more to improve this. I added links to prior conversations I was reading through recently at the top.

gh67uyyghj — January 21, 2020 at 11:16 PM UTC

As always with everything Go authors do, they are ready to do anything and everything but the right thing even if it was as straightforward as typical enum implementations. Type safety is treated as a bug in this language

ianlancetaylor — January 28, 2020 at 10:29 PM UTC

It's not clearly specified how to know which set of values are permitted for a type used with .(const). Do the values have to be in a single const block? Presumably they all have to be the same type.

ianlancetaylor — January 28, 2020 at 10:29 PM UTC

As this is a language change proposal, could you please fill out the template at https://go.googlesource.com/proposal/+/bd3ac287ccbebb2d12a386f1f1447876dd74b54d/go2-language-changes.md .

When you are done, please reply to the issue with @gopherbot please remove label WaitingForInfo.

Thanks!

ermik — February 4, 2020 at 4:54 AM UTC

@gopherbot please remove label WaitingForInfo

ermik — February 4, 2020 at 5:10 AM UTC

@ianlancetaylor thank you for welcoming these thoughts and guiding me through the process. The template requirement allowed me to introduce additional thoughts on how this would work, but I am hoping there is room for further discussion before a final decision is made. Please review the template and let me know if it can be improved.

As I had to make the leap and use const in a function signature — for the sake of complete picture when it comes to this proposal's scope — I've also bastardized a number of other possible declarations with it. Function declarations in public scope accepting enum arguments and public structs with enum fields are the two culprits and introducing the const syntax there will allow to decisively shift responsibility to the client. But I can see how that may be too much to ask. After all, the mere presence of enums in these declarations and the availability of the new .(const) may be a solid foundation for the slight ambiguity we have today, i.e. leaving it to the (responsible) APIs author to actively check the incoming values before use.

Lastly, again on the subject of time and proposal review schedule. I will make my best effort to stay with this and be responsive, but hope for some decent lag to be accepted without pulling the plug. Sometime life gets in the way.

ianlancetaylor — February 4, 2020 at 10:58 PM UTC

The comment at https://github.com/golang/go/issues/36387#issuecomment-579493203 needs to be addressed: how exactly do we know which constant values are associated such that the .(const) syntax applies to them? What if the same type has const values defined in multiple blocks? What if they are defined in different packages? What if some of the const values happen to have the same value?

Since we can use this for const strings, the x, ok := x.(const) syntax is surprisingly expensive, as it will have to compile into a series of string comparisons. In Go we generally try to avoid the case in which a simple expression has unpredictable execution time.

ermik — February 5, 2020 at 1:27 AM UTC

I've addressed the overall concern in the code example, perhaps it was too brief and sneaky. Here are some more detailed thoughts:

In the proposal template code example I've highlighted the following:

var method string = "GET"
method, ok := method.(const) // ERR: unable to check value of unnamed type against known constants

which disallows a plain string to be matched against all possible string constants.

The user either must use a named type (which has a known, fixed set of values to check against) or the switch v.(const) syntax where the user defines the set irregardless of named types by specifying a number of constants in case statements to match against.

ianlancetaylor — February 5, 2020 at 1:37 AM UTC

If we aggregate valid constant values across packages, then the behavior of .(const) might depend on the exact set of packages that are imported. That doesn't sound right.

ermik — February 5, 2020 at 2:06 AM UTC

That's a good point. I didn't see the potential implications of that, although that's the allowance currently present with type Method string declaration (other packages can declare more constants of Method type).

Do you think a requirement of a single block declaration is viable as an alternative extreme to the "everywhere" approach? I am uncertain.

ianlancetaylor — February 5, 2020 at 2:09 AM UTC

I'm not sure what the best answer is, but I'm fairly sure that it shouldn't vary depending on which packages have been imported.

ermik — February 5, 2020 at 2:10 AM UTC

I agree. Adding an import shouldn't break or implicitly change behavior of existing code.

ermik — February 6, 2020 at 3:04 PM UTC

We could say that a given constant type is internal to the package that defines it, adopting the behavior of protecting the struct types from external method definition:

func (s *pkg.Import) String() string // ERR

Such approach would limit the .(const) scope to said package without preventing that syntax usage higher in package tree.

There just isn't (yet) a way to distinguish a type:

type Method string

as a "constant" type... It makes sense though — the types defined using that syntax for the purpose of being a constant enumeration are rarely used as a general purpose variable. The only case is the type casting, where we say Method(str) to unsafely attempt matching one of the known values.

At the same time, a definition of a "constant type" would eliminate v, ok := v.(const) as if v is of primitive type, then constant matching should be written v, ok := v.(Method) making v an implicit interface{} type. That's interesting, isn't it? 😃 (

The switch v.(const) could still be the exhaustive addition for when v is of constant type we can require either all cases to be considered or a default case to be present.


Except the famous iota example, are there currently differences between how declarations in a single block and multiple blocks are treated?

ianlancetaylor — February 6, 2020 at 10:28 PM UTC

Except the famous iota example, are there currently differences between how declarations in a single block and multiple blocks are treated?

No.

jimmyfrasche — February 7, 2020 at 8:05 PM UTC

Constants declared in the same package as their defined type are often enums—but not always. When they are, what an individual package means by enum is not at all uniform.

I'm not sure if what is meant by enum here is the best definition, but I do like that it tries to piggyback on ordinary consts as much as possible.

Maybe if there were a special way to more tightly associate consts with their type, like say

const type T int (
  A = iota
  B
  C

  synonym = C
)
const regular = B

All the constants in that block are implicitly type T. They cannot be another type. The labels A, B, and C and their values are associated with T. synonym is considered an alternate label for C.

regular is just an ordinary constant. It is not associated with T.

Otherwise, it's the same as

type T int
const (
  A T = iota
  B
  C

  synonym = C
)
const regular = B

This association could just mean that those (label, value) pairs are stored in the export data with T and accessible via reflect and go/types.

That would allow a lot of linters and libraries for handling various enum-ish things to be created to handle various enum semantics. You can kind of do that now for linters but it gets complicated since you don't know which consts are associated with the type, per the above definition. Runtime checking requires the associated labels and values to be added to the program either manually or with code generation.

The notion of associated label/values could also allow some special syntax like .(const) or range T as discussed above, but it would also be possible to use reflect to write it as library code (that could be replaced by code generated with go/types if necessary).

ermik — February 7, 2020 at 10:08 PM UTC

@jimmyfrasche thanks for chiming in. I will try to comment and clarify, what you and @ianlancetaylor shared is very helpful.

The reason I had idea for "piggybacking" on the ordinary const is that I personally believe that enumeration is a pattern, not a type. With all of its troubles we're discussing here, the enum pattern implementation in Go is a reflection of the language as a whole — clear, concise, "to the point".

It seems like a lot of issues have been created by the ambiguity of the syntax I proposed, which refers to the constant scope in question using const keyword, rather than scope itself. I also alluded to the necessity of clearing values as "known constants" before passing them to certain methods by specifying const in the function signature.

I now see that my affection for the const keyword is unhelpful. Throwing it at every one of the issues we have discussed here and elsewhere in the community is a mistake. Its distinct nature is of interest, but its presence is redundant. Perhaps a single extension of its use makes more sense:

type LetterIndex const int
//---------------^^^^^---- restricts this type to declarations of integer constants

const (
    LetterIndexA LetterIndex = iota+1
    LetterIndexB
    LetterIndexC
)

Further declarations of typed constants within package scope amend the set of known values:

const LetterIndexZ LetterIndex = 26 // valid

Declarations that duplicate values defined earlier in code are synonyms and simply point to the address of the unique value in memory:

const (
    Last = LetterIndexZ
    First LetterIndex = 0
)

The precedence and unique requirement encourage "single block" declaration, without restricting odd uses.

Declaration of a const int named type does invalidate type conversion:

var cast = LetterIndex(1) // ERR: cannot convert 1 (untyped int constant) to type LetterIndex

But we introduce two new ways of retrieving a value of a named type:

// Type assertion
var v = 1
cast, ok := v.(LetterIndex) // cast = LetterIndexA, ok = true

// Exhaustive switch
switch v.(LetterIndex) {
case A, B, C:
    break
default: // required, "25" was not handled
    break
}

Finally, the compiler is using a known set (list of unique values) to accommodate these new features. It is reasonable to expose this set to the users as well:

fmt.Printf("%+v", values(LetterIndex))
// [LetterIndexA, LetterIndexB, LetterIndexC, LetterIndexZ]

You can see that I replaced the use of const in the syntax I proposed earlier with the exact scope being targeted in code.

jsshapiro — May 30, 2022 at 10:28 PM UTC

Regarding the absence of a proper enum type in Go, a quick observation: the source code sequence

type Blah int

const (
  First Blah = iota
  ... // further enumerands
)

Can be viewed as a very verbose way of writing

enum Blah (
  First Blah = iota
  ... // further enumerands
)

but with the advantage that the underlying representation size of the enum is stated explicitly.

I was initially disconcerted by this surface syntax, but it's a modestly clever bit of sparsity in the language design.

jsshapiro — May 30, 2022 at 10:37 PM UTC

One of the patterns that is occasionally used in enumerations looks like:

type MyEnum int
const (
  First MyEnum = iota
  //...
  group_beg
  Element1
  Element2
  group_end
  //...
)

You can see this pattern used in the go Token enum here.

In this pattern, you wouldn't usually expect group_beg and group_end to be used in a switch - they exist for range comparisons.

There are variants on this pattern where group_beg would have the same value as Element1 and the upper bound would be the start of the next group. In this case you'd see the value appear in the switch, but not the actual identifier.

How does your proposal interact with this existing practice?