TypeScriptのスキーマ検証ライブラリといえば Zod が有名ですよね。
他の言語にもそのようなライブラリがないかなと興味本位でリサーチしていたところ、Zog というGo用のスキーマ検証ライブラリを見つけました。
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でお馴染みの書き方になりますね。
import (
z "github.com/Oudwins/zog"
)
プリミティブ型
スキーマ定義はこのような形式になります。Zodとほとんど同じ書き方ですね。
// 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()
メソッドを使い、第一引数に検証したいデータを、第二引数にパース先の変数へのポインタを指定します。
// パース後のデータの格納先
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
)が返されます。(エラーについてで後述)
またパースに失敗した場合でも格納先の変数には値が代入されます。
構造体型
構造体型でもやってみましょう。
// 格納するデータの型定義
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
に格納されます。
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
型のスライス)が返されます。
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型)が返されます。
構造体の場合
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文字以上の文字列である文字列型スライス」を定義しています。
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
として変換してくれます。
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
として変換してくれます。
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
が含まれているか検証しています。
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以上の整数であることを検証した後、2倍してから変数に格納しています。
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
が代わりに入ります。
z.Struct(z.Schema{
"name": z.String().Required().Min(3).Default("Unknown"),
"age": z.Int(),
})
Catch
値がない場合(ゼロ値の場合)に加えて、値にエラーがある場合も全てデフォルト値に置き換えます。
次の場合name
フィールドが存在しない場合や空文字の場合に加えて、3文字未満の場合もUnknown
が代わりに入ります。
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/ を読んでみてください。