Goのnil sliceと空sliceの返却契約をレビューでどう判断するか
Goのnil sliceと空sliceの返却契約をレビューでどう判断するか
Goでは、nil のsliceと長さ0のsliceはどちらも len(s) == 0 になる。
内部処理だけなら同じように扱える場面も多い。
しかしAPI応答や外部契約が絡むと、話は変わる。
nil sliceはJSONで null になり、空sliceは [] になるため、呼び出し側に見える意味が変わる。
この記事では、nil sliceを常に禁止するのではなく、
どの層で返却契約を固定すべきかをレビュー視点で整理する。
まず止めたい実装
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件のとき items は nil のまま返る。
ハンドラでそのままJSON化すると、レスポンスは次のようになる。
{"items":null}API利用者が期待しているのが [] なら、これは互換性の問題になる。
なぜ危ないのか
nil sliceと空sliceの違いは、Go内部では小さく見える。
しかし外部境界では、次のような差になる。
- JSONで
nullと[]が変わる - TypeScriptやSwiftなどのクライアント型が変わる
- 呼び出し側に
items == nil分岐が増える - テストで「0件」の期待値が揺れる
- API仕様書と実レスポンスがずれる
レビューで大事なのは、nilか空かの好みではない。
その戻り値が内部表現なのか、外部契約なのかを分けることだ。
レビューで見たい3つの判断線
1. 外部APIの配列項目は [] を契約にしているか
REST APIやJSONレスポンスでは、配列項目を常に配列として返す設計が多い。
その場合、空結果は null ではなく [] に固定したい。
@Reviewer: このフィールドはAPI上の配列契約に見えます。空結果で `null` が返るとクライアント側の分岐が増えるため、レスポンス境界で空sliceへ正規化してください。2. Repository層で契約を固定しすぎていないか
DBアクセス層で常に make([]T, 0) する方針もある。
ただし、すべての内部処理で空slice契約が必要とは限らない。
@Reviewer: nil sliceを内部表現として使うこと自体は問題ありません。ただし外部レスポンスへ出る境界では `[]` と `null` の契約差が見えるため、どの層で正規化するかを明示してください。レビューでは、Repository、Service、Handlerのどこで契約を固定するかを見る。
API契約の問題なら、レスポンスDTOで正規化するほうが責務が読みやすいことが多い。
3. nilが「未取得」や「非表示」を意味していないか
nil sliceに業務上の意味を持たせ始めると危険だ。
type OrderResponse struct {
Items []OrderItem `json:"items"`
}
// nil: 権限がないので未取得
// empty: 取得したが0件@Reviewer: `nil` に「未取得」や「権限なし」の意味を持たせると、JSON上の `null` と空配列の違いに業務状態が混ざります。状態は別フィールドやエラーで表現してください。改善例:レスポンス境界で正規化する
APIレスポンスとして配列契約を固定するなら、ハンドラや変換関数で正規化する。
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も自然なゼロ値として扱える。
func FilterActive(users []User) []User {
var active []User
for _, user := range users {
if user.Active {
active = append(active, user)
}
}
return active
}この関数が内部でしか使われず、JSON契約に直結しないなら、nil sliceを返しても大きな問題にならない。
レビューで過剰に直すより、外部境界に出るかどうかを見たい。
レビュー観点チェックリスト
- そのsliceは外部API、JSON、RPC、テンプレートに出るか
- API仕様として
[]とnullのどちらを返す契約か - nilに「未取得」「権限なし」「省略」のような業務意味を持たせていないか
- 正規化の責務がRepository、Service、Handlerのどこにあるか
- 空結果のテストでJSON文字列または構造を確認しているか
- 内部処理のゼロ値まで過剰に修正していないか
まとめ
Goのnil sliceと空sliceは、内部処理では似ていても外部境界では意味が変わる。
レビューでは、nil を機械的に禁止するのではなく、返却値が契約として見える場所かどうかを判断したい。
APIの配列項目なら、空結果は [] として固定するほうが利用者に優しい。
その正規化をどの層で行うかまで含めて、レビューで確認する価値がある。