Goのnil sliceと空sliceの返却契約をレビューでどう判断するか

Goでは、nil のsliceと長さ0のsliceはどちらも len(s) == 0 になる。
内部処理だけなら同じように扱える場面も多い。

しかしAPI応答や外部契約が絡むと、話は変わる。
nil sliceはJSONで null になり、空sliceは [] になるため、呼び出し側に見える意味が変わる

この記事では、nil sliceを常に禁止するのではなく、
どの層で返却契約を固定すべきかをレビュー視点で整理する。

まず止めたい実装

空結果がnullになる実装
func ListOrderItems(ctx context.Context, db *sql.DB, orderID string) ([]OrderItem, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT id, name, quantity
        FROM order_items
        WHERE order_id = ?
    `, orderID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []OrderItem
    for rows.Next() {
        var item OrderItem
        if err := rows.Scan(&item.ID, &item.Name, &item.Quantity); err != nil {
            return nil, err
        }
        items = append(items, item)
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }

    return items, nil
}

func GetOrderItemsHandler(w http.ResponseWriter, r *http.Request) {
    items, err := ListOrderItems(r.Context(), db, r.PathValue("orderID"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(struct {
        Items []OrderItem `json:"items"`
    }{
        Items: items,
    })
@Reviewer
空結果時にitemsがnilのままだとJSONでは`"items": null`になります。API契約が配列なら、レスポンス境界で`[]`に正規化してください。
}

この実装では、注文商品が0件のとき itemsnil のまま返る。
ハンドラでそのままJSON化すると、レスポンスは次のようになる。

{"items":null}

API利用者が期待しているのが [] なら、これは互換性の問題になる。

なぜ危ないのか

nil sliceと空sliceの違いは、Go内部では小さく見える。
しかし外部境界では、次のような差になる。

  • JSONで null[] が変わる
  • TypeScriptやSwiftなどのクライアント型が変わる
  • 呼び出し側に items == nil 分岐が増える
  • テストで「0件」の期待値が揺れる
  • API仕様書と実レスポンスがずれる

レビューで大事なのは、nilか空かの好みではない。
その戻り値が内部表現なのか、外部契約なのかを分けることだ。

レビューで見たい3つの判断線

1. 外部APIの配列項目は [] を契約にしているか

REST APIやJSONレスポンスでは、配列項目を常に配列として返す設計が多い。
その場合、空結果は null ではなく [] に固定したい。

Comment
@Reviewer: このフィールドはAPI上の配列契約に見えます。空結果で `null` が返るとクライアント側の分岐が増えるため、レスポンス境界で空sliceへ正規化してください。

2. Repository層で契約を固定しすぎていないか

DBアクセス層で常に make([]T, 0) する方針もある。
ただし、すべての内部処理で空slice契約が必要とは限らない。

Comment
@Reviewer: nil sliceを内部表現として使うこと自体は問題ありません。ただし外部レスポンスへ出る境界では `[]` と `null` の契約差が見えるため、どの層で正規化するかを明示してください。

レビューでは、Repository、Service、Handlerのどこで契約を固定するかを見る。
API契約の問題なら、レスポンスDTOで正規化するほうが責務が読みやすいことが多い。

3. nilが「未取得」や「非表示」を意味していないか

nil sliceに業務上の意味を持たせ始めると危険だ。

nilに複数の意味が混ざる例
type OrderResponse struct {
    Items []OrderItem `json:"items"`
}

// nil: 権限がないので未取得
// empty: 取得したが0件
Comment
@Reviewer: `nil` に「未取得」や「権限なし」の意味を持たせると、JSON上の `null` と空配列の違いに業務状態が混ざります。状態は別フィールドやエラーで表現してください。

改善例:レスポンス境界で正規化する

APIレスポンスとして配列契約を固定するなら、ハンドラや変換関数で正規化する。

API境界で空sliceに正規化する実装
type OrderItemsResponse struct {
    Items []OrderItem `json:"items"`
}

func NewOrderItemsResponse(items []OrderItem) OrderItemsResponse {
    if items == nil {
        items = make([]OrderItem, 0)
    }

    return OrderItemsResponse{
        Items: items,
    }
}

func GetOrderItemsHandler(w http.ResponseWriter, r *http.Request) {
    items, err := ListOrderItems(r.Context(), db, r.PathValue("orderID"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(NewOrderItemsResponse(items))
}

この形なら、Repository層はDB取得に集中し、API境界でJSON契約を固定できる。

レビュー観点としても次が読みやすい。

  • 内部表現と外部契約が分かれている
  • items は常にJSON配列として返る
  • nilに業務上の追加意味を持たせていない
  • テスト対象がレスポンス変換に分かれる

nil sliceを許容してよいケース

nil sliceをすべて直す必要はない。
内部処理では、nil sliceも自然なゼロ値として扱える。

内部処理ならnil sliceでも自然な例
func FilterActive(users []User) []User {
    var active []User
    for _, user := range users {
        if user.Active {
            active = append(active, user)
        }
    }
    return active
}

この関数が内部でしか使われず、JSON契約に直結しないなら、nil sliceを返しても大きな問題にならない。
レビューで過剰に直すより、外部境界に出るかどうかを見たい。

レビュー観点チェックリスト

nil slice / 空sliceレビューの確認項目
  • そのsliceは外部API、JSON、RPC、テンプレートに出るか
  • API仕様として []null のどちらを返す契約か
  • nilに「未取得」「権限なし」「省略」のような業務意味を持たせていないか
  • 正規化の責務がRepository、Service、Handlerのどこにあるか
  • 空結果のテストでJSON文字列または構造を確認しているか
  • 内部処理のゼロ値まで過剰に修正していないか

まとめ

Goのnil sliceと空sliceは、内部処理では似ていても外部境界では意味が変わる。
レビューでは、nil を機械的に禁止するのではなく、返却値が契約として見える場所かどうかを判断したい。

APIの配列項目なら、空結果は [] として固定するほうが利用者に優しい。
その正規化をどの層で行うかまで含めて、レビューで確認する価値がある。