GoでEnumを自前実装したら結構使えた話

この記事は [Go 言語 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/go) 6日目の記事です。

# はじめに

GoでEnumってどう実現するんだという話をSNSでたびたび目撃します。
[GoとEnumについて](https://zenn.dev/nobonobo/articles/986fea54fdc1c1)にベストプラクティスは紹介されていますが、今回は私の関わっているチームで自前実装した方式を紹介します。

すでに本番導入している方式で、数年間本番で追加開発/保守運用する中でもうまく機能していると感じています。

# 実装したもの

こんな感じです。
定義ファイルから以下のコードを生成して使っています。

```order_status_gen.go
// Code generated; DO NOT EDIT.

// 追加でメソッドを定義したい場合は、別ファイルでメソッドを追加してください。
// 別ファイルはこのファイルから、「_gen」を取った名前で作成してください。
// 例：hogehoge_gen.go -> hogehoge.go

package enum

import (
	"fmt"
	"sort"
	"strconv"
)

type OrderStatusEnum string

var OrderStatus = struct {
	Requested      OrderStatusEnum
	WorkInProgress OrderStatusEnum
	Done           OrderStatusEnum
	Canceled       OrderStatusEnum
	opts           map[string]map[string]interface{}
}{
	Requested:      OrderStatusEnum("requested"),
	WorkInProgress: OrderStatusEnum("work_in_progress"),
	Done:           OrderStatusEnum("done"),
	Canceled:       OrderStatusEnum("canceled"),
	opts: map[string]map[string]interface{}{
		"requested": {
			"ja":            "依頼中",
			"ordinal":       "0",
			"is_cancelable": true,
		},
		"work_in_progress": {
			"ja":            "作業中",
			"ordinal":       "1",
			"is_cancelable": false,
		},
		"done": {
			"ja":            "完了",
			"ordinal":       "2",
			"is_cancelable": false,
		},
		"canceled": {
			"ja":            "キャンセル",
			"ordinal":       "3",
			"is_cancelable": false,
		},
	},
}

// ToOrderStatus は引数に該当するOrderStatusEnumを返却する。
// 該当するOrderStatusがない場合、nilを返す。
func ToOrderStatus(val string) (*OrderStatusEnum, error) {
	switch val {
	case OrderStatus.Requested.String():
		return &OrderStatus.Requested, nil
	case OrderStatus.WorkInProgress.String():
		return &OrderStatus.WorkInProgress, nil
	case OrderStatus.Done.String():
		return &OrderStatus.Done, nil
	case OrderStatus.Canceled.String():
		return &OrderStatus.Canceled, nil
	}
	return nil, fmt.Errorf("OrderStatusEnum への変換に失敗しました value: %v", val)
}

// AllOrderStatusValues は OrderStatusEnum の一覧を返す。
func AllOrderStatusValues() []OrderStatusEnum {
	values := []OrderStatusEnum{
		OrderStatus.Requested,
		OrderStatus.WorkInProgress,
		OrderStatus.Done,
		OrderStatus.Canceled,
	}

	sort.Slice(values, func(i, j int) bool {
		ordinalI, _ := strconv.Atoi(values[i].Ordinal())
		ordinalJ, _ := strconv.Atoi(values[j].Ordinal())
		return ordinalI < ordinalJ
	})

	return values
}

// Equals は引数と同じOrderStatusEnumであるかを判断する。
// 同じ場合にtrueを返す。
func (e *OrderStatusEnum) Equals(obj OrderStatusEnum) bool {
	return *e == obj
}

// In は引数の中に同一のOrderStatusEnumがあればtrueを返す。
func (e *OrderStatusEnum) In(objs ...OrderStatusEnum) bool {
	for _, obj := range objs {
		if e.Equals(obj) {
			return true
		}
	}

	return false
}

// String はOrderStatusのValueを返す。
func (e *OrderStatusEnum) String() string {
	return string(*e)
}

// Ja はOrderStatusEnumのjaの属性を返す。
func (e *OrderStatusEnum) Ja() string {
	return OrderStatus.opts[e.String()]["ja"].(string)
}

// Ordinal はOrderStatusEnumのordinalの属性を返す。
func (e *OrderStatusEnum) Ordinal() string {
	return OrderStatus.opts[e.String()]["ordinal"].(string)
}

// IsCancelable はOrderStatusEnumのis_cancelableの属性を返す。
func (e *OrderStatusEnum) IsCancelable() bool {
	return OrderStatus.opts[e.String()]["is_cancelable"].(bool)
}
```

これらのEnumファイルはenumパッケージ内に全て配置するようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105059/46280baa-141c-e922-3d42-2a1920635e53.png)


このEnumを使うとこんなふうにコードが書けます。
```sample.go
package main

import (
	"fmt"
	"github.com/foo/bar/enum"
)

func main() {
	status := enum.OrderStatus.Requested // enum -> OrderStatus -> Requested という順にコード補完できる。

	if status.In(enum.OrderStatus.Done, enum.OrderStatus.Canceled) { // enum.OrderStatusEnum 型を引数にしているので他の型を渡すとコンパイルエラーになる。
		// hogehoge
	}

	echo(status)
}

func echo(status enum.OrderStatusEnum) { // enum.XxxxxEnum という型で引数定義。
	fmt.Println(status)
}
```

# 推しどころ
* `enum.` と打つとコード補完でかなり絞られるのでEnum名を覚えてなくても探し出しやすいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105059/5e2b29de-afc6-871b-915c-7861a139b9bb.png)

* コード生成にプラスして以下のように_genなしのファイルを作成してメソッドを追加することでenum->enum変換機能なども実装できます。例えば FooTypeからBarGroupへのEnum変換も以下のように定義できます。テストで変換定義漏れの検知可能です。

```foo_type.go
package enum

func (e *FooTypeEnum) ToBarGroup() BarGroupEnum {
	bar, err := ToBarGroup(e.BarGroupString)
	if err != nil {
		panic(err) // ユニットテストでpanicしないことを担保している。
	}
	
	return bar
}
```

```foo_type_test.go
package enum

import (
	"testing"
)

func TestFooTypeEnum_ToBarGroup(t *testing.T) {
	for _, typ := range AllFooTypeValues() {
		t.Run(typ.String(), func(t *testing.T) {
			// panic にならないかのテスト。
			t.Log(typ.ToBarGroup())
		})
	}
}
```

<details><summary>bar_group_gen.go</summary>

```bar_group_gen.go
// 省略
var BarGroup = struct {
	Hoge      BarGroupEnum
	Piyo BarGroupEnum
	opts           map[string]map[string]interface{}
}{
// 省略
```
</details>

<details><summary>foo_type_gen.go</summary>

```foo_type_gen.go
package enum

import (
	"fmt"
	"sort"
	"strconv"
)

type FooTypeEnum string

var FooType = struct {
	BarHoge1 FooTypeEnum
	BarHoge2 FooTypeEnum
	BarPiyo1 FooTypeEnum
	opts: map[string]map[string]interface{}{
		"bar_hoge1": {
			"ja":               "ばーほげ1",
			"ordinal":          "0",
			"bar_group_string": "hoge",
		},
		"bar_hoge2": {
			"ja":               "ばーほげ2",
			"ordinal":          "1",
			"bar_group_string": "hoge",
		},
		"bar_piyo1": {
			"ja":               "ばーぴよ1",
			"ordinal":          "2",
			"bar_group_string": "piyo",
		},
	},
}
// 省略
```
</details>

# 注意点

* Enumファイル（order_status_gen.goなど）は定義ファイルからコード生成しているのでmapの存在チェックなどはしていないです（template情報は後述しています）。

* const 定義ではないのでEnum値は上書き可能です。ここはコードレビューと開発者の良心で秩序を保っています。今まで上書きしてバグったという事故は0件です。
```go
func main() {
	fmt.Println(enum.OrderStatus.Requested) // requested

	enum.OrderStatus.Requested = "canceled" // こんなことしちゃダメ！！

	fmt.Println(enum.OrderStatus.Requested) // canceled
}
```

# コード生成テンプレート

私の関わっているチームでは以下のような定義情報をパースして後述するEnumGeneratorに値をセットをしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105059/0c1a2f24-3f7d-7c46-7f00-13fb71bff017.png)

コードテンプレートを記載しておきます。

<details><summary>enum.tmpl</summary>

```enum.tmpl
// Code generated; DO NOT EDIT.

// 追加でメソッドを定義したい場合は、別ファイルでメソッドを追加してください。
// 別ファイルはこのファイルから、「_gen」を取った名前で作成してください。
// 例：hogehoge_gen.go -> hogehoge.go

package enum

import (
    "fmt"
	"sort"
	"strconv"
)

type {{.ClassPascal}}Enum string

var {{.ClassPascal}} = struct {
{{- range $enumMember := .EnumMembers}}
	{{$enumMember.NamePascal}} {{$.ClassPascal}}Enum
{{- end}}
	opts map[string]map[string]interface{}
}{
{{- range $enumMember := .EnumMembers}}
	{{$enumMember.NamePascal}}: {{$.ClassPascal}}Enum("{{$enumMember.Value}}"),
{{- end}}
	opts: map[string]map[string]interface{}{
{{- range $enumMember := .EnumMembers}}
		"{{$enumMember.Value}}": {
	{{- range $enumOpt := $enumMember.EnumOpts}}
		{{- if $enumOpt.IsBoolValue }}
			"{{$enumOpt.Key}}": {{$enumOpt.Value}},
		{{- else}}
			"{{$enumOpt.Key}}": "{{$enumOpt.Value}}",
		{{- end}}
	{{- end}}
		},
{{- end}}
	},
}

// To{{.ClassPascal}} は引数に該当する{{.ClassPascal}}Enumを返却する。
// 該当する{{.ClassPascal}}がない場合、nilを返す。
func To{{.ClassPascal}}(val string) (*{{$.ClassPascal}}Enum, error) {
	switch val {
{{- range $enumMember := .EnumMembers}}
	case {{$.ClassPascal}}.{{$enumMember.NamePascal}}.String():
		return &{{$.ClassPascal}}.{{$enumMember.NamePascal}}, nil
{{- end}}
	}
	return nil, fmt.Errorf("{{.ClassPascal}}Enum への変換に失敗しました value: %v", val)
}

// All{{.ClassPascal}}Values は {{.ClassPascal}}Enum の一覧を返す。
func All{{.ClassPascal}}Values() []{{.ClassPascal}}Enum {
	values := []{{.ClassPascal}}Enum{
    {{- range $enumMember := .EnumMembers}}
            {{$.ClassPascal}}.{{$enumMember.NamePascal}},
    {{- end}}
	}

	sort.Slice(values, func(i, j int) bool {
	    ordinalI, _ := strconv.Atoi(values[i].Ordinal())
	    ordinalJ, _ := strconv.Atoi(values[j].Ordinal())
	    return ordinalI < ordinalJ
	})

	return values
}

// Equals は引数と同じ{{.ClassPascal}}Enumであるかを判断する。
// 同じ場合にtrueを返す。
func (e *{{.ClassPascal}}Enum) Equals(obj {{.ClassPascal}}Enum) bool{
	return *e == obj
}

// In は引数の中に同一の{{.ClassPascal}}Enumがあればtrueを返す。
func (e *{{.ClassPascal}}Enum) In(objs ...{{.ClassPascal}}Enum) bool{
	for _, obj := range objs {
		if e.Equals(obj) {
			return true
		}
	}

	return false
}

// String は{{.ClassPascal}}のValueを返す。
func (e *{{.ClassPascal}}Enum) String() string{
	return string(*e)
}

{{- range $opt := .EnumMembers 0}}
// {{$opt.KeyPascal}} は{{$.ClassPascal}}Enumの{{$opt.Key}}の属性を返す。
	{{- if $opt.IsBoolValue }}
func (e *{{$.ClassPascal}}Enum) {{$opt.KeyPascal}}() bool{
	return {{$.ClassPascal}}.opts[e.String()]["{{$opt.Key}}"].(bool)
}
	{{- else}}
func (e *{{$.ClassPascal}}Enum) {{$opt.KeyPascal}}() string{
	return {{$.ClassPascal}}.opts[e.String()]["{{$opt.Key}}"].(string)
}
	{{- end}}

{{- end}}
```
</details>

<details><summary>enum.tmplに渡すパラメータ＆コード生成の構造体（EnumGenerator）</summary>

enum.tmplをもとにコード生成するgeneratorのコードを記載します。

```enum_generator.go
//go:embed template/enum.tmpl
var enumTemplate string

type EnumGenerator struct {
	enumMappers []*enumMapper
}

type enumMapper struct {
	// ClassSnake はEnum名をスネークケースにしたもの。
	ClassSnake string
	// ClassCamel はEnum名をキャメルケースにしたもの。
	ClassCamel string
	// ClassCamel はEnum名をパスカルケースにしたもの。
	ClassPascal string
	// EnumMembers はEnumの値の属性情報。
	EnumMembers []enumMember
}

type enumMember struct {
	// NamePascal はEnumの値の識別子（パスカルケース）。
	NamePascal string
	// NamePascal はEnumの値。
	Value    string
	// EnumOpts はEnumの値の属性情報。
	EnumOpts []enumOpt
}

type enumOpt struct {
	// Key はEnumの値の属性のキー。
	Key         string
	// Value はEnumの値の属性の値。
	Value       string
	// KeyPascal はEnumの値の属性のキー（パスカルケース）。
	KeyPascal   string
	// IsBoolValue はEnumの値の属性の値がbool型ならtrue, そうでなければstring型として扱う。
	IsBoolValue bool
}

// Generate はEnumを生成する。
// outputPath は出力先のフォルダ。
func (g *EnumGenerator) Generate(outputPath string) error {
	tmpl, err := template.New("go").Parse(enumTemplate)
	for _, entity := range g.enumMappers {
		if err != nil {
			return err
		}
		f, err := os.Create(path.Join(outputPath, entity.ClassSnake+"_gen.go"))
		if err != nil {
			return err
		}
		if err := tmpl.Execute(f, entity); err != nil {
			return err
		}
		f.Close()
	}

	return gofmt(outputPath)
}

func gofmt(path string) error {
	if err := exec.Command("gofmt", "-w", path).Run(); err != nil {
		return err
	}

	return exec.Command("goimports", "-w", path).Run()
}
```

```データイメージ.json
{
  "ClassSnake": "order_status",
  "ClassCamel": "orderStatus",
  "ClassPascal": "OrderStatus",
  "EnumMembers": [
    {
      "NamePascal": "Requested",
      "Value": "requested",
      "EnumOpts": [
        {
          "Key": "ja",
          "Value": "",
          "KeyPascal": "Ja",
          "IsBoolValue": false
        },
        {
          "Key": "ordinal",
          "Value": "0",
          "KeyPascal": "Ordinal",
          "IsBoolValue": false
        },
        {
          "Key": "is_cancelable",
          "Value": "true",
          "KeyPascal": "IsCancelable",
          "IsBoolValue": true
        }
      ]
    },
    ...
  ]
}
```
</details>

# おわりに
Enumのコード量は決して少なくないのですが、そこをコード生成を作り切れば、あとは結構楽に扱える形になっていると思います。気に入った方はぜひ試してみていただければ嬉しいです。