Skip to content

proposal: spec: exhaustive switching for enum type-safety  #36387

@ermik

Description

@ermik
  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    Intermediate, multiple projects, plenty of learning

  • What other languages do you have experience with?
    JS/TS, Swift, Python, C#, C++

  • Has this idea, or one like it, been proposed before?
    Yes, the enums are a popular under proposal subject matter.

  • If so, how does this proposal differ?
    It doubles down on the status quo (aka historically idiomatic) solution for enums in Go. All current Go enum declarations will remain valid. Instead of introducing new keywords or interfering with existing patterns (such as the Stringer interface) it adds tools for enforcement and runtime-safety of enumeration usage. It does so by employing existing type assertion and type casting idioms, and the const keyword idiomatic to Go enum declarations.

  • Who does this proposal help, and why?
    For those adopting enums in Go, it is important to limit their use to the values known at compile time, or explicitly opt-in to the type-casting and therefore verifying the primitive values. The new syntax will not eliminate the need for authors of packages exposing an enumeration type to create checks on enum usage. Rather, this proposal will clarify the party responsible for enum value validation and provide tools that will make that validation idiomatic, clearly identifiable and easy to implement.

  • Is this change backward compatible?
    Yes.

  • Show example code before and after the change.
    I used http package as an example but I do not advocate redeclaring standard library constants as enums.
    Before

    const (
        MethodGet     = "GET"
        // ...
    )
    func NewRequest(method, url string, body io.Reader) (*Request, error) {
        switch method {
        case MethodGet:
            // ...
        default:
            return nil, ErrUnknownMethod
        }
    }

    After (please see comment below for an evolved, but still work in progress update to the proposed changes)

    type Method string
    
    const (
        MethodGet Method = "GET"
        // ...
    )
    
    // NewRequest with original signature allows for freeform string method
    func NewRequest(method, url string, body io.Reader) (*Request, error) {
        // method, ok := method.(const) // ERR: can't check untyped string value against known constants
    
        // but if the user provides a set of constants to check, we can use a switch:
        switch method.(const) {
        case MethodGet:
            // ...
        default:
            // The default case in a .(const) switch is a requirement
            return nil, ErrUnknownMethod
        }
    
        // Or we can cast to a named type, effectively switching over cases for each of enum's constant values
        method, ok := Method(method).(const)
        if (!ok) {
            return nil, ErrUnknownMethod
        }
    
        // Now that method is a known constant, the .(const) switch may drop its default clause
        // ...
    }
    
    // NewRequestEnum requires enum type but has no opinion on its source
    // For the caller, it makes it clear the first argument is unlike the second.
    func NewRequestEnum(m Method, url string, body io.Reader) (*Request, error) {
        // ...
    }
    
    // NewRequestConst is not callable from if using Method cast from a primitive type without performing .(const) check.
    // For the caller, it makes it clear there is a set of values of named type already provided.
    func NewRequestConst(m const Method, url string, body io.Reader) (*Request, error) {
        // This is a "MAYBE??": addition to the syntax, borrowing from "chan type",
        // "const type" requires caller to use a pre-defined constant value
        // or use switch.(const) and .(const) type-cast to fulfill that obligation
        // when sourcing from a primitive type.
        // See comment below for me pulling back from this.
    }
  • What is the cost of this proposal? (Every language change has a cost).
    The new syntax clashes with .(type) and the const addition to function signature argument is opening a can of worms.

  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
    New syntax would depend on linting and formatting to ensure ease of adoption.

  • What is the compile time cost?
    Compiler would need to identify all references to a named const type and ensure the binary has type-specific facilities for executing .(const) checks. Additionally, when a function signature (or perhaps even a struct field) requires const argument, it must identify the call site and ensure the .(const) conversion takes place prior to call.

  • What is the run time cost?
    During program execution the new .(const) checks are done against switch cases or statically instanced collections of pre-defined values. If the type casting for enum values is present in the binary, and the new syntax is employed unsafely (e.g. the .(const) casting omits ok variable), the panic messaging must clearly identify the mismatch between available pre-defined enum constants and the result of the runtime-cast of from a primitive type.

  • Can you describe a possible implementation?
    No. I don't have a familiarity with the Go source code. Point me in the right direction and I would love to contribute.

  • Do you have a prototype? (This is not required.)
    No, but willing to collaborate.

  • How would the language spec change?
    I don't have an off-hand grasp of EBNF notation, but in layman's terms the spec will extend type assertion, type switch, and function parameter usage to make possible the new syntax.

  • Orthogonality: how does this change interact or overlap with existing features?
    It makes use of type assertion and type casting syntax as the basis.

  • Is the goal of this change a performance improvement?
    No.

  • Does this affect error handling?
    Yes, the advancement of enum support in Go through this or another proposal must ensure more elaborate analysis at exception site. In case of this proposal, it is now possible to identify

  • If so, how does this differ from previous error handling proposals?
    I don't know.

  • Is this about generics?
    No.

Original ideas and research

Prior Art

In no specific order:

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 (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 consts 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 #19814 (comment)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions