Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goの型パラメータを使って型付きバリデータを作っている

型パラメータ(generics)とerrors.Joinを使ってバリデータを作っています。

github.com

経緯

Goで値のバリデーションを行う場合、有名なライブラリには以下の2つがあります。

go-playground/validator はよくある構造体のフィールドに validate:"required" のようにタグを付けるスタイルのバリデータです。awesome-goで紹介されているvalidatorのほとんどはこのスタイルで人気もありますが、個人的には validate:"required" 程度ならともかく validate:"oneof='red green' 'blue'" あたりからは、手軽さよりも複雑さの方が強くて少々厳しいなと感じます。

一方、 go-ozzo/ozz-validation の方はコードで記述するスタイルのバリデータです。公式のサンプルを借用すると、

a := Address{
    Street: "123",
    City:   "Unknown",
    State:  "Virginia",
    Zip:    "12345",
}
err := validation.ValidateStruct(&a,
    validation.Field(&a.Street, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.City, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.State, validation.Required, validation.Match(regexp.MustCompile("^[A-Z]{2}$"))),
    validation.Field(&a.Zip, validation.Required, validation.Match(regexp.MustCompile("^[0-9]{5}$"))),
)
fmt.Println(err)

とても印象は良かったのですが、残念ながら2020年ごろからメンテされなくなっており、IssueやPull requestの状況をみる限りは枯れて安定している様子もないので、今このライブラリに依存するのも怖いなと思います。

go-ozzo/ozzo-validation から派生したリポジトリjellydator/validationというリポジトリがあり、これは今もメンテされているようですが、Go 1.18で入った型パラメータ(generics)とか、1.20で入った errors.Join を使ってみたい気持ちがありました。

そういう理由で、上で挙げた go-validator を自作しています。

go-validatorの使い方

例えば以下のコードでは、createUserRequestValidatorValidate(context.Context, *CreateUserRequest) メソッドを持つバリデータです。

import (
    "context"

    "github.com/lufia/go-validator"
)

var createUserRequestValidator = validator.Struct(func(s validator.StructRule, r *CreateUserRequest) {
    validator.AddField(s, &r.Name, "name", validator.Length[string](5, 20))
    validator.AddField(s, &r.Provider, "provider", validator.In(Google, Apple, GitHub))
    validator.AddField(s, &r.Theme, "theme", validator.In("light", "dark"))
})
err := createUserRequestValidator.Validate(context.Background(), &CreateUserRequest{})
fmt.Println(err)

実装するときに気をつけたことをいくつか挙げます。

型のあるバリデータ

バリデータはanyではなく個別の型を持たせています。これは必ず達成したいと思っていました。Goの型パラメータは関数引数などから推論されるので、上記では validator.Struct に渡している関数や validator.In に渡している値の型などからバリデータの型を決定しています。

同様に、構造体フィールドも型推論によって決定したかったので、validator.AddField にはフィールドのポインタを渡すようにしています。Validateの内部では r の先頭ポインタとのオフセットを使って reflect でフィールドを探しています。

複数のエラーを扱う

複数の項目で違反があった場合は errors.Join で複数のエラーを返すようになっています。個別のエラーをコード側から扱いたい場合は errors.Asinterface { Unwrap() []error } などで掘る必要があって少し面倒ですが、Go 1.20時点で複数のエラーを扱う場合の標準的な方法なのでそれに合わせています。

国際化対応

go-validator では golang.org/x/text/message でエラーメッセージの飜訳をしています。

本当は、ライブラリ側では素朴にエラーを返して、使う側で飜訳してもらう方が好ましいとは思うのですが、バリデータでは

  • 複数のエラーを返す場合がある
  • 構造体やスライスをバリデーションする場合は階層構造になることがある

などがあり、使う側でエラーを飜訳してもらうのも難しそうだったのでライブラリ側で対応しました。言語を切り替えたい場合は context.Context に言語タグを設定しておくと切り替わりますし、ライブラリ側の飜訳では不十分なら独自のカタログを用意する方法もあります。

難しかったところ

メソッドで型パラメータを宣言したい

Go 1.20時点では、メソッドで新しく型パラメータを宣言できません。

// これはできない
func (p *userType) Method[T any](v T)

何度か欲しくなったけれどできないので、別のgenericな型を用意して対応しました。

type Type[T any] struct{...}

// これはできる
func (p *Type[T]) Method(v T)

具体的な型引数を省略できない場合がある

複数の値のうちどれかを選択する validator.In バリデータや、値の範囲が一定以上になっていることを検査する validator.Min バリデータなどは、引数から型推論できるため型引数を省略できますが、値が必須であることの検査をする validator.Required バリデータなどは推論するための型がないので型引数を省略できません。

今の時点では仕方がないので、これは諦めました。今後リリースされるGoのバージョンでは、代入先の型から推論したり、デフォルトの型引数が定義できたりなど拡張されていくようなので期待しています。

lenの引数にできる型の制約を書けない

簡単にできそうだけど意外と困難です*1

len() できる型は

  • string
  • []T
  • map[K]V
  • chan T

などありますが、string には型パラメータが不要だけどスライスには1つ必要、マップはキーと値で2つ必要です。型パラメータを3つ持たせて必要なところだけ使うようにすればできなくはないですが、その実現方法はどうなんだ?とは思います。

基底型がstringまたはStringメソッドを実装している型の制約が書けない

String() string を実装していれば文字列のように扱えると便利じゃないかなと思って、

type stringable interface {
    ~string | String() string
}

としてみたところエラーになりました。

type stringable interface {
    ~string
    String() string
}

これはエラーにはならないけれど、ANDとして扱われるようでうまく実現できませんでした。

参考

Goの型パラメータについてとても詳しくまとまっている情報なのでぜひ読んでください。

*1:できなくはないけど歪なものになる、が正しい