emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Go 1.24 から導入された json の IsZero に触れてみる

Overview

Go 1.24 から encoding/json に入った IsZero のインターフェースについて実際に触ってみて挙動を調べてみました。

IsZero とは?

When marshaling, a struct field with the new omitzero option in the struct field tag will be omitted if its value is zero. If the field type has an IsZero() bool method, that will be used to determine whether the value is zero. Otherwise, the value is zero if it is the zero value for its type. The omitzero field tag is clearer and less error-prone than omitempty when the intent is to omit zero values. In particular, unlike omitempty, omitzero omits zero-valued time.Time values, which is a common source of friction. If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both).

tip.golang.org

リリースノートを参考にすると今まで time.Time に入っていた IsZero メソッドを生やしたカスタムフィールドに対して json タグで omitzero を付与することでいわゆる「ゼロ値」の振る舞いを json.Marshal, Unmarshal の中で制御することができるようになるようです。

サンプル実装

まず標準の time.Time を渡してみます。

// You can edit this code!
// Click here and start typing.
package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type X struct {
    A time.Time `json:"a,omitzero"`
}

func main() {
    x := X{}
    b, _ := json.Marshal(x)
    fmt.Printf("%v", string(b))
}

ref: https://go.dev/play/p/nkBKMnKym7i

この出力以下のようになります。

{}

続いてカスタム型に IsZero メソッドを生やしてみます。

// You can edit this code!
// Click here and start typing.
package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type X struct {
    A time.Time `json:"a,omitzero"`
    B Y         `json:"b,omitzero"`
}

type Y struct {
    UserID int
}

func (y Y) IsZero() bool {
    fmt.Println("aaaa")
    return true
}

func main() {
    x := X{}
    b, _ := json.Marshal(x)
    fmt.Printf("%v", string(b))
}

ref: https://go.dev/play/p/sDVeZEzEZlm

この出力は以下のようになります。

aaaa
{}

これは IsZero メソッドの返り値が true (= つまりどんな値をとっても omitzero で消える) なので出力結果は空になります。

ただ少しコードを変更してみます。

// You can edit this code!
// Click here and start typing.
package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type X struct {
    A time.Time `json:"a,omitzero"`
    B Y         `json:"b,omitzero"`
}

type Y struct {
    UserID int
}

func (y Y) IsZero() bool {
    return y.UserID == 0
}

func main() {
    x := X{
        B: Y{
            UserID: 1,
        },
    }
    b, _ := json.Marshal(x)
    fmt.Printf("%v", string(b))
}

ref: https://go.dev/play/p/roo_uNbSGKI

この出力は以下のようになります。

{"b":{"UserID":1}}

Y の中にある UserID が 0 のときは omitzero で消えるようにしてるので、0 以外を指定したときには omitzero の対象になりません。

振る舞いとユースケース

この omitzero (IsZero メソッド)の導入により、今まで json の key から消すには json で Unmarshal するフィールドの定義を参照型にする必要があり、これが意図しない nilpo の温床になっていたり、クライアントアプリから見るとあるはずの key がない、と言ってフロントのエンバグに繋がることもありましたが、今回 の omitzero の導入で「どういうときにゼロとするか?」という仕様を決めることができるようになると思いました。Go の実装としても不要な参照型を排除することで nilpo を踏む可能性を少なくできますし、より堅牢なプログラムを書けるようになりそうです。

また別のユースケースとしては、A/B テストや本番と開発環境(テスト環境等)で特定のフィールドを入れる、入れないを IsZero の中で制御し、クライアントの振る舞いを変更するといったことも実装の深いところではなく、JSON を作るより上層(Converter や View といったレイヤー)で行うことができて不要な依存を深いところに埋め込まなくて済む、といったことも考えられそうです。

まとめ

最初は今更 encoding/json におけるゼロ値の取り扱いを再定義することのメリットは何なのか、ぱっとわかりませんでしたが、実際に実装してみると少しわかりました。使っていきたいと思います。