Go版Zod? スキーマ検証ライブラリ Zog を使ってみた

TypeScriptのスキーマ検証ライブラリといえば Zod が有名ですよね。

https://zod.dev/

他の言語にもそのようなライブラリがないかなと興味本位でリサーチしていたところ、**Zog** というGo用のスキーマ検証ライブラリを見つけました。

https://zog.dev/

`Zod-Inspired Next Gen Schema Validation for Golang`というモットーの通り、Zodにインスパイアを受け、GoでZodライクなバリデーションを行えるライブラリとして開発されています。
2025年1月末現在のバージョンは`0.14.1`なのでまだ開発途中の可能性が高いですが、今回は現在リリースされている時点の機能の範囲でZogを使ってみましたので、ご紹介したいと思います。

(動作環境)
- Go 1.23.1
- MacOS Sonoma 14.7.3

## 基本的な使い方

まずインポートしましょう。
`z`というインポート名にするとZodでお馴染みの書き方になりますね。
```go:インポート
import (
	z "github.com/Oudwins/zog"
)
```

### プリミティブ型

スキーマ定義はこのような形式になります。Zodとほとんど同じ書き方ですね。

```go:スキーマ定義
// 3文字以上10文字以下の文字列
schema1 := z.String().Min(3).Max(10)

// 3以上10未満の整数
schema2 := z.Int().GTE(3).LT(10)

// 1.1, 2.2, 3.3のいずれかの小数
schema3 := z.Float().OneOf([]float64{1.1, 2.2, 3.3})
```

パースしてみましょう。`Parse()`メソッドを使い、第一引数に検証したいデータを、第二引数にパース先の変数へのポインタを指定します。

```go:パース
// パース後のデータの格納先
var dest1 string
var dest2 int
var dest3 float64

// パース
errList1 := schema1.Parse("abcde", &dest1) // パース成功
fmt.Println(dest1)                         // "abcde"
errList2 := schema2.Parse(2, &dest2)       // パース失敗
fmt.Println(dest2)                         // 2（パースに失敗したが、値は入る）
errList3 := schema3.Parse(2.2, &dest3)     // パース成功
fmt.Println(dest3)                         // 2.2
```
Zodとは違って、パース後のデータは`Parse()`メソッドの返り値で取得するのではなく、第二引数のポインタで指定した変数に格納されます。
`Parse()`メソッドの返り値はエラーになります。パースに成功した場合は`nil`が、失敗した場合は`ZogError`のスライス(`ZogErrList`)やMap(`ZogErrMap`)が返されます。（[エラーについて](#エラーについて)で後述）
またパースに失敗した場合でも格納先の変数には値が代入されます。

### 構造体型

構造体型でもやってみましょう。
```go:構造体型
// 格納するデータの型定義
type User struct {
	Id    int
	Name  string
	Email string
	Age   int
}

// 格納先
u := User{}

// スキーマ定義
schema := z.Struct(z.Schema{
    "id": z.Int().GT(0).Required(),
    "name": z.String().Min(1).Max(50).Required(),
    "email": z.String().Email().Required(),
    "age": z.Int().GTE(18).Optional(),
})

// パース
errsMap := schema.Parse(map[string]any{
    "id":    1,
    "name":  "Zog Sample",
    "email": "zog@example.com",
    "age":   20,
}, &u)
```
Zodとは違い、格納先の構造体の型は自分で定義する必要があります。（TypeScriptで書くZodの場合、`z.infer<typeof schema>`のようにスキーマから型を抽出できました）
**スキーマで定義されているフィールドが格納先の構造体にない場合、パニックになる**ので注意してください。

スキーマと構造体型のフィールド名は必ず一致している必要がありますが、検証するデータのフィールド名がスキーマと一致しない場合は、`zog:`構造体タグを使って関連付けることができます。
例えば次の場合、検証するデータの`id`と`name`はそれぞれ、スキーマの`Bangou`と`Namae`に格納されます。
```go:構造体タグで違うフィールド名を関連付け
type User2 struct {
	Bangou int    `zog:"id"`
	Namae  string `zog:"name"`
	Email  string
	Age    int
}
u2 := User{}

schema2 := z.Struct(z.Schema{
    "bangou": z.Int().GT(0).Required(),
    "namae":  z.String().Min(1).Max(50).Required(),
    "email":  z.String().Email().Required(),
    "age":    z.Int().GTE(18).Optional(),
})

errsMap2 := schema.Parse(map[string]any{
    "id":    2,
    "name":  "Zog Sample2",
    "email": "zog2@example.com",
    "age":   25,
}, &u2)
```

## エラーについて

### プリミティブ型スキーマのエラー

プリミティブ型のスキーマに関するバリデーションエラーに対しては、`ZogErrorList`型（`ZogError`型のスライス）が返されます。

```go
var dest string
errList := z.String().Min(5).Email().Parse("foo", &dest)

for i, err := range errList {
    fmt.Printf("errList[%d]: %s\n", i, err)
}
```
これのエラー出力は次のようになります。最低文字数エラーと、不正なメール形式の合計2件が格納されています。
```
errList[0]: ZogError{Code: min, Params: map[min:5], Type: string, Value: foo, Message: 'string must contain at least 5 character(s)', Error: <nil>}
errList[1]: ZogError{Code: email, Params: map[], Type: string, Value: foo, Message: 'must be a valid email', Error: <nil>}
```

### 複合型スキーマのエラー

構造体型やスライス型のスキーマに関するバリデーションエラーに対しては、`ZogErrorMap`型（エラーのあるフィールド名をキー、該当する`ZogErrorList`型を値にしたMap型）が返されます。

#### 構造体の場合

```go
var dest struct {
    Name  string
    Email string
}
errsMap := z.Struct(z.Schema{
    "name":  z.String().Required(),
    "email": z.String().Min(5).Email(),
}).Parse(map[string]any{
    "email": "foo",
}, &dest)

for k, errList := range errsMap {
    fmt.Printf("errsMap[%s]:\n", k)
    for i, err := range errList {
        fmt.Printf("  errList[%d]: %s\n", i, err)
    }
}
```

これのエラー出力は次のようになります。Mapのキー名がエラーのあるフィールド名になっています。

```
errsMap[$first]:
  errList[0]: ZogError{Code: required, Params: map[], Type: string, Value: <nil>, Message: 'is required', Error: <nil>}
errsMap[name]:
  errList[0]: ZogError{Code: required, Params: map[], Type: string, Value: <nil>, Message: 'is required', Error: <nil>}
errsMap[email]:
  errList[0]: ZogError{Code: min, Params: map[min:5], Type: string, Value: foo, Message: 'string must contain at least 5 character(s)', Error: <nil>}
  errList[1]: ZogError{Code: email, Params: map[], Type: string, Value: foo, Message: 'must be a valid email', Error: <nil>}
```
よく見ると、`name`と`email`以外に、`$first`というキーがあります。内容は`name`の項目と同一です。`$first`は最初に検出したエラーを示しています。エラー項目を全て表示する必要がない場合は重宝するかもしれません。

#### スライス型の場合

次のスキーマは、「要素が4つ以上で、かつそれぞれの要素は5文字以上の文字列である文字列型スライス」を定義しています。

```go
var dest []string
errsMap := z.Slice(z.String().Min(5)).Min(4).Parse([]string{"aaaaa", "bbbb", "cccc"}, &dest)

for k, errList := range errsMap {
    fmt.Printf("errsMap[%s]:\n", k)
    for i, err := range errList {
        fmt.Printf("  errList[%d]: %s\n", i, err)
    }
}
```
これのエラー出力は次のようになります。

```
errsMap[$root]:
  errList[0]: ZogError{Code: min, Params: map[min:4], Type: slice, Value: [aaaaa bbbb cccc], Message: 'slice must contain at least 4 items', Error: <nil>}
errsMap[$first]:
  errList[0]: ZogError{Code: min, Params: map[min:5], Type: string, Value: bbbb, Message: 'string must contain at least 5 character(s)', Error: <nil>}
errsMap[[1]]:
  errList[0]: ZogError{Code: min, Params: map[min:5], Type: string, Value: bbbb, Message: 'string must contain at least 5 character(s)', Error: <nil>}
errsMap[[2]]:
  errList[0]: ZogError{Code: min, Params: map[min:5], Type: string, Value: cccc, Message: 'string must contain at least 5 character(s)', Error: <nil>}
```
`$root`という項目があります。これはスライス自体に要素数が足りないというエラーがあるためです。ルートレベルでのエラーがある場合に使用されます。

### エラーのサニタイズ

このようにZogのエラー出力は詳細に構造化されていますが、煩雑に感じる場合は`z.Errors.SanitizeList()`や`z.Errors.SanitizeMap()`を使うと、エラーメッセージだけを抽出できます。

`ZogErrorList`の場合、`z.Errors.SanitizeList()`を使うとエラーメッセージが入ったスライス`[]string`として変換してくれます。

```go
var dest string

errList := z.String().Min(5).Email().Parse("foo", &dest)
messageList := z.Errors.SanitizeList(errList)

for i, message := range messageList {
    fmt.Printf("messageList[%d]: %s\n", i, message)
}
```
出力は次のようになります。
```
messageList[0]: string must contain at least 5 character(s)
messageList[1]: must be a valid email
```

`ZogErrorMap`の場合、`z.Errors.SanitizeMap()`を使うと、エラー項目ごとにエラーメッセージのスライスが入った`map[string][]string`として変換してくれます。

```go
var dest []string

errsMap := z.Slice(z.String().Min(5)).Min(4).Parse([]string{"aaaaa", "bbbb", "cccc"}, &dest)
sanitizedMap := z.Errors.SanitizeMap(errsMap)

for k, messageList := range sanitizedMap {
    fmt.Printf("sanitizedMap[%s]:\n", k)
    for i, message := range messageList {
        fmt.Printf("  messageList[%d]: %s\n", i, message)
    }
}
```
出力は次のようになります。
```
sanitizedMap[$first]:
  messageList[0]: string must contain at least 5 character(s)
sanitizedMap[[1]]:
  messageList[0]: string must contain at least 5 character(s)
sanitizedMap[[2]]:
  messageList[0]: string must contain at least 5 character(s)
sanitizedMap[$root]:
  messageList[0]: slice must contain at least 4 items
```
    
## 色々な使い方

### 値の変換

#### preTransform

受け取った値を変換してから検証したい場合は`preTransform`を使います。Zodでいう、`preprocess`に相当する箇所です。
次の例は小文字を大文字に変換してから、`A`が含まれているか検証しています。

```go
var dest string

schema := z.String().PreTransform(func(data any, ctx z.ParseCtx) (any, error) {
    return strings.ToUpper(data.(string)), nil
}).Contains("A")

errList := schema.Parse("abcde", &dest) // パース成功
fmt.Println(dest)                       // ABCDE
```

注意点として、`preTransform`はParse前の処理のため、受け取るデータ&変換後のデータが`any`型になります。そのため型アサーションが必要になる場合が多いです。（上のコードだと`data.(string)`の部分）
想定外の型のデータが来て型アサーションに失敗するとパニックになります。

#### postTransform

値を検証した後、値を変換してから変数に格納したい場合は`postTransform`を使います。Zodでいう、`transform`に相当する箇所です。
次の例は0以上の整数であることを検証した後、２倍してから変数に格納しています。

```go
var dest int

schema := z.Int().GTE(0).PostTransform(func(dataPtr any, ctx z.ParseCtx) error {
    ptr := dataPtr.(*int)
    *ptr *= 2
    return nil
})

errList := schema.Parse(10, &dest)  // パース成功
fmt.Println(dest)                   // 20
```
`dataPtr`の箇所が格納先の変数へのポインタとなります。ポインタ上の値を取得してからそれを書き換えています。

### その他

Zodにある次のような処理も同じ名前でZogに実装されています。軽く紹介します。

#### Default

値がない場合（ゼロ値の場合）にエラーを出す代わりにデフォルト値として入る値を指定できます。
次の場合`name`フィールドが存在しない場合や空文字の場合、`Unknown`が代わりに入ります。

```go
z.Struct(z.Schema{
    "name": z.String().Required().Min(3).Default("Unknown"),
    "age":  z.Int(),
})
```

#### Catch

値がない場合（ゼロ値の場合）に加えて、値にエラーがある場合も全てデフォルト値に置き換えます。
次の場合`name`フィールドが存在しない場合や空文字の場合に加えて、3文字未満の場合も`Unknown`が代わりに入ります。

```go
z.Struct(z.Schema{
    "name": z.String().Required().Min(3).Catch("Unknown"),
    "age":  z.Int(),
})
```

## まとめ

今回はスキーマ検証ライブラリZogを使って、GoでZodライクなバリデーションをやってみました。
Zodを使ったことのある人は似たような構文で書くことができますし、Zodを使ったことない人にも比較的使いやすいライブラリなのではないかと思います。
まだメジャーバージョンが`0`ですが、将来安定版が登場すればTypeScriptのZodのように人気のライブラリとなるかもしれませんね。期待したいです。
他にも色々な使い方やメソッドがあるので詳しくは <https://zog.dev/> を読んでみてください。