Go の nil スライスと空スライスの違い:使い分けと JSON での挙動

Go には「nil スライス」と「空スライス」という、見た目は似ているが内部的に異なる 2 つの状態があります。多くの場面では同じように動きますが、JSON エンコードやリフレクション、nil チェックなど、違いが表面化する場面を知っておかないと思わぬバグに悩まされます。

nil スライスと空スライスの違い

var nilSlice []int            // nil スライス
emptySlice := []int{}         // 空スライス
emptyMake := make([]int, 0)   // 空スライス(make 版)
nil スライスポインタが nil。len = 0、cap = 0
空スライスポインタが非 nil(空の配列を指す)。len = 0、cap = 0

長さと容量はどちらも 0 ですが、内部のポインタが異なります。nil スライスはポインタそのものが nil で、空スライスはサイズ 0 の配列を指すポインタを持っています。

ほとんどの操作で区別不要

日常的なスライス操作では、nil と空の違いを意識する必要はほぼありません。

var s []int // nil スライス

fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0

s = append(s, 1, 2, 3)
fmt.Println(s) // [1 2 3]

for _, v := range s {
	fmt.Println(v) // 正常に動く
}

lencapappendfor range はすべて nil スライスに対して安全に動作します。nil チェックをせずに append できるのは Go の便利な特性で、初期化を忘れてもパニックしません。

// これは安全(パニックしない)
var items []string
items = append(items, "hello")

// これも安全
var nums []int
for _, n := range nums {
	fmt.Println(n) // 実行されないだけ
}

違いが出る場面:nil チェック

== nil による比較では、当然ながら結果が異なります。

var nilSlice []int
emptySlice := []int{}

fmt.Println(nilSlice == nil)  // true
fmt.Println(emptySlice == nil) // false

これが問題になるのは、「値が設定されていない」ことを nil で表現している場合です。

type SearchResult struct {
	Items []Item
}

func search(query string) SearchResult {
	if query == "" {
		return SearchResult{} // Items は nil
	}

	items := []Item{} // 検索結果 0 件
	// ... 検索処理 ...
	return SearchResult{Items: items}
}

呼び出し側で result.Items == nil をチェックすると、「検索していない」と「検索したが結果が 0 件」を区別できます。ただし、この区別に依存する設計はバグの温床になりやすいため、できれば別のフィールド(Searched bool など)で明示するほうが安全です。

違いが出る場面:JSON エンコード

もっとも実務で問題になるのが JSON の出力です。

type Response struct {
	Users []User `json:"users"`
}

func main() {
	// nil スライス
	r1 := Response{}
	data1, _ := json.Marshal(r1)
	fmt.Println(string(data1))
	// {"users":null}

	// 空スライス
	r2 := Response{Users: []User{}}
	data2, _ := json.Marshal(r2)
	fmt.Println(string(data2))
	// {"users":[]}
}
nil スライス → JSON

"users":null として出力される。クライアントによっては null と空配列を区別するため、予期しない挙動の原因になる。

空スライス → JSON

"users":[] として出力される。多くの API クライアントが期待する形式。配列型のフィールドには空配列を返すのが一般的。

フロントエンドの JavaScript では null[] で処理が変わることがあります。response.users.length は空配列なら 0 を返しますが、null だと TypeError になります。API のレスポンスでは、配列型のフィールドには null ではなく空配列を返すのがベストプラクティスです。

空配列を保証するパターン

API レスポンスで確実に [] を返すための実装パターンがいくつかあります。

// パターン 1: 初期化時に空スライスを作る
func listUsers() Response {
	users := []User{} // nil ではなく空スライス

	// ... DB からの取得処理 ...

	return Response{Users: users}
}
// パターン 2: 返却前に nil チェック
func listUsers() Response {
	users := fetchFromDB()

	if users == nil {
		users = []User{}
	}

	return Response{Users: users}
}
// パターン 3: MarshalJSON で制御
func (r Response) MarshalJSON() ([]byte, error) {
	type Alias Response
	a := Alias(r)
	if a.Users == nil {
		a.Users = []User{}
	}
	return json.Marshal(a)
}

パターン 1 が最もシンプルで、チーム全体で「配列は必ず空スライスで初期化する」というルールを決めておくのが現実的でしょう。

JSON デコード時の挙動

逆方向、つまり JSON からのデコード時にも違いがあります。

type Input struct {
	Tags []string `json:"tags"`
}

func main() {
	// tags が存在しない
	var i1 Input
	json.Unmarshal([]byte(`{}`), &i1)
	fmt.Println(i1.Tags == nil) // true

	// tags が null
	var i2 Input
	json.Unmarshal([]byte(`{"tags":null}`), &i2)
	fmt.Println(i2.Tags == nil) // true

	// tags が空配列
	var i3 Input
	json.Unmarshal([]byte(`{"tags":[]}`), &i3)
	fmt.Println(i3.Tags == nil) // false
	fmt.Println(len(i3.Tags))   // 0
}
JSON にキーがないnil スライスのまま
JSON で nullnil スライスになる
JSON で []空スライス(非 nil)になる

「キーなし」と「null」がどちらも nil になるため、区別したい場合はポインタ型(*[]string)を使うか、json.RawMessage で生データを確認する必要があります。

reflect.DeepEqual の挙動

テストで reflect.DeepEqual を使う場合も注意が必要です。

var nilSlice []int
emptySlice := []int{}

fmt.Println(reflect.DeepEqual(nilSlice, emptySlice)) // false

nil スライスと空スライスは DeepEqual で等しくないと判定されます。テストで「要素が 0 個であること」を検証したいなら、len で比較するほうが安全です。

// 脆い: nil と空で結果が変わる
assert.Equal(t, []int{}, result)

// 堅い: 長さだけをチェック
assert.Equal(t, 0, len(result))

実務では「nil か空かを気にしなくて済む設計」を目指すのが最善です。API レスポンスでは空スライスで初期化し、内部ロジックでは len(s) == 0 で判定する。この 2 つのルールを徹底するだけで、nil と空の違いに起因するバグのほとんどを防げます。