はじめに (対象読者・この記事でわかること)

この記事は、「JSON のフィールドの型が条件によって変わってしまう Web API に接続しなければならない」という状況に直面している Go エンジニアを対象としています。
具体的には、同じフィールドが文字列だったり数値だったり、さらにオブジェクトになることもあるような「型不定」なレスポンスを、一括で構造体へ unmarshal しつつ、後から型安全に値を取り出したい方です。

この記事を読むことで

  • interface{} を活用して「どんな型でも一旦受け入れる」設計の仕方
  • 型アサーションと型スイッチで実行時に安全に値を取り出す実装パターン
  • 一つの構造体で複数の型を扱いつつ、ビジネスロジック側で「どの型でも扱いやすくする」ヘルパーの書き方

が身に付きます。
サンプルコードはそのままコピペ+αで動く構成になっているので、すぐにプロダクトへ転用できるでしょう。

前提知識

  • Go の構造体タグ(json:"name")の基本
  • encoding/json パッケージを使った unmarshal の経験
  • インターフェース(interface)と型アサーション(v.(T))の存在を知っていること

なぜ「型が変わる JSON」がつらいのか

近年の Web API では「バージョン違いでフィールドの型が変わる」「エラー時は文字列、正常時はオブジェクト」といった仕様に遭遇することがあります。
Go らしく構造体を定義して unmarshal しようとすると「型が違う」旨のエラーで失敗します。
そこで「どんな型でも入れられる箱」が必要になり、それが interface{} です。
本記事では、interface{} を経由して「一旦受け入れ→後から型を見て処理」を可能にする設計を紹介します。

interface{}+型スイッチで型不定 JSON を一括 unmarshal する

ステップ 1:共通の入れ物を作る

まず「型が変わりうるフィールド」を interface{} で宣言します。
他のフィールドは普通に string / int などで OK です。

Go
package main import ( "encoding/json" "fmt" "log" ) type Event struct { ID string `json:"id"` Type string `json:"type"` Status interface{} `json:"status"` // ここが不定 CreatedAt int64 `json:"created_at"` }

ステップ 2:一括で unmarshal してみる

Go
func main() { // 例1: status が文字列 jsonStr1 := `{"id":"1","type":"order","status":"running","created_at":1680000000}` // 例2: status がオブジェクト jsonStr2 := `{"id":"2","type":"order","status":{"progress":80,"eta":"10m"},"created_at":1680001000}` for _, src := range []string{jsonStr1, jsonStr2} { var e Event if err := json.Unmarshal([]byte(src), &e); err != nil { log.Fatal(err) } fmt.Printf("ID=%s, raw status=%v\n", e.ID, e.Status) } }

この時点で unmarshal は成功し、e.Status には
- 例1 では string
- 例2 では map[string]interface{}
が入っています。

ステップ 3:型スイッチで安全に値を取り出す

ビジネスロジック側で「status が進行中か?」を判定したい場合を考えます。

Go
func IsRunning(st interface{}) bool { switch v := st.(type) { case string: return v == "running" case map[string]interface{}: // オブジェクト版 return v["progress"] != nil && v["progress"] != float64(0) default: return false } }
Go
fmt.Println("running?", IsRunning(e.Status))

ステップ 4:ヘルパーメソッドで構造体に振る舞いを持たせる

各所で型スイッチを書くと重複するので、構造体メソッド化します。

Go
func (e Event) IsRunning() bool { return IsRunning(e.Status) } func (e Event) ProgressPercent() (int, bool) { switch v := e.Status.(type) { case map[string]interface{}: if p, ok := v["progress"].(float64); ok { return int(p), true } } return 0, false }

これで呼び出し側は

Go
if e.IsRunning() { if p, ok := e.ProgressPercent(); ok { fmt.Printf("進行中: %d%%\n", p) } }

とシンプルに書けます。

ステップ 5:カスタム unmarshal を併用したい場合

「status を必ず string にしたい」「オブジェクトの場合は JSON 文字列化して保持したい」という要件なら、カスタム型に UnmarshalJSON を実装します。

Go
type FlexibleStatus struct { Value interface{} } func (fs *FlexibleStatus) UnmarshalJSON(data []byte) error { // 1. まず文字列としてトライ if data[0] == '"' { var s string if err := json.Unmarshal(data, &s); err != nil { return err } fs.Value = s return nil } // 2. オブジェクト or 配列 var m interface{} if err := json.Unmarshal(data, &m); err != nil { return err } fs.Value = m return nil }

Event.StatusFlexibleStatus 型に置き換えるだけで、独自ルールを適用できます。

ハマりがちなポイントと解決策

  1. 数値が float64 になる
    JSON 仕様上、数値はすべて float64 扱い。v.(int) とアサーションすると panic します。
    v.(float64) で一旦受けてから int(v) するか、json.Number を使う

  2. nil チェックを怠ると panic
    型アサーションは v.(T) のみだと panic します。
    v, ok := v.(T) の 2 値返却フォームを使い、!ok 時の分岐を書く

  3. map キーが存在しないとゼロ値
    m["progress"] が存在しない場合のゼロ値と、進捗 0 を区別できない。
    → 構造体にマッピングする際は「存在フラグ」を別途持つ or *int にしてポインタで有無を表現

まとめ

本記事では、Go において「フィールドの型がブレる JSON」を interface{} で一旦受け入れ、型スイッチ+型アサーションで実行時に安全に値を取り出す方法を解説しました。

  • interface{} を使えば unmarshal 失敗を回避できる
  • 型スイッチで分岐し、ビジネスロジックに合わせたヘルパーを書くことで、呼び出し側はシンプルに書ける
  • 数値は float64 になる、nil チェックは必須、などの落とし穴を抑えれば実装は安定する

このテクニックを使えば、サードパーティ API の破壊的変更にも「とりあえず動く」状態を保ちながら、段階的に型を厳格にしていく、という運用が可能になります。
次回は「型が確定したら generics を使ってより厳格に扱う」アプローチを紹介する予定です。

参考資料