14
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-05

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:構造体タグを使って関連付けることができます。
例えば次の場合、検証するデータのidnameはそれぞれ、スキーマのBangouNamaeに格納されます。

構造体タグで違うフィールド名を関連付け
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>}

よく見ると、nameemail以外に、$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/ を読んでみてください。

14
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?