見出し画像

Go の使い方|nil なのに nil じゃない|typed nil の罠と、その仕組み

if err != nil {
    // ← エラーは起きていないはずなのに、なぜかここに入る
}

エラーを返す関数を書いて、いつものように if err != nil で受けた。なのに、エラーなど起きていないはずの入力で、この中に入ってしまう。しかも err の中身を見ると「空っぽ」に見える。Go を触り始めた頃の私が、半日つぶした詰まりです。

この記事では、Go を触り始めた人に向けて、この「nil なのに nil じゃない」現象= typed nil(型付き nil) の罠を、どこを見れば正体にたどり着くかを実演します。伝えたいことは1つ、「nil かどうかは、値だけでなく『型』も見て決まる」ことです。インターフェースは中に「型」と「値」の2つを持っていて、値が nil でも型が入っていれば、そのインターフェースは nil ではありません。ここを Go の型システムの仕組みから解きます。

これは nil シリーズの一本です。No.1 の nil ポインタ参照が「ポインタのゼロ値」の話だったのに対し、今回は「インターフェースの nil 比較」という別のレイヤーを扱います。

🧱 エラーは返していないのに、if err != nil に入る

ありがちな場面です。バリデーション用に、独自のエラー型を作って返す関数を書いたとします。ビルドは通ります。

package main

import "fmt"

// 独自エラー型(ポインタ・レシーバで Error を実装)
type ValidationError struct {
	Msg string
}

func (e *ValidationError) Error() string {
	return e.Msg
}

// ★ 戻り値の型が *ValidationError(具体型ポインタ)になっているのが落とし穴
func validate(name string) *ValidationError {
	if name == "" {
		return &ValidationError{Msg: "name is empty"}
	}
	return nil // 問題なければ nil を返す(つもり)
}

func run(name string) error {
	return validate(name) // *ValidationError(nil) を error に代入して返す
}

func main() {
	err := run("gopher") // 正しい名前なので、エラーは無いはず
	fmt.Printf("err == nil ? %v\n", err == nil)
}

"gopher" はちゃんとした名前なので、validate は nil を返します。だから err == nil は true になってほしい。ところが実行するとこうなります。実際の出力を要約・整形した想定例です(Go 1.24 で確認)。

err == nil ? false

nil を返したはずなのに、err == nil が false。詰まりどころは、だいたいこう分かれます。

  • nil を返しているのに、なぜ err が nil じゃないのか分からない

  • err を fmt.Println(err) で表示すると空に見える(あるいは panic する)ので、余計に混乱する

  • とりあえず if err != nil && err.Error() != "" のような場当たり条件で切り抜けようとする

最後のが、いちばん危ない対処です。条件を盛れば一見動きますが、本当の問題は「具体型のポインタを error インターフェースに詰め替えた」ことにあります。そこを直さないと、別の関数で同じ罠をまた踏みます。

🔍 何を見たか:%T で「動的な型」を炙り出す

err == nil が false になるとき、まず打つ一手が fmt.Printf の %T(型)と %v(値)です。err の「動的な型」と「値」を同時に出します。

fmt.Printf("type=%T value=%v\n", err, err)

想定される出力はこうです(Go 1.24 で確認。Error() を %v が呼ぶため値は <nil> 相当の表示になります)。

type=*main.ValidationError value=<nil>

ここが決定的です。**値は <nil> なのに、型として *main.ValidationError がしっかり入っている**。この「型は付いているのに値が nil」という状態が typed nil です。

切り分けの手順を、再現できる形でまとめます。

  1. err == nil が想定外の結果になったら typed nil を疑う — 「返したはずの nil」が効いていない

  2. fmt.Printf("%T %v", x, x) で動的な型と値を出す — 型欄に具体型(*main.ValidationError など)が出ていれば確定

  3. その値を生んだ関数の戻り値の型を確認する — 戻り値が error(インターフェース)ではなく、*ValidationError(具体型ポインタ)になっていないか

  4. 具体型で nil を返し、それをインターフェースに代入している箇所を特定する — 今回は run の return validate(name)

4番まで来ると、原因は「nil を返したこと」ではなく、「具体型ポインタの nil を error インターフェースに詰め替えたこと」だと確定します。

💡 なぜ「nil を返したのに nil じゃない」のか:インターフェースは (型, 値) の2枚組

ここが、この記事でいちばん伝えたい部分です。Go のインターフェースは、内部的に「型(type)」と「値(value)」の2つの要素で表されます。Go FAQ「Why is my nil error value not equal to nil?」には、こう書かれています。

Under the covers, interfaces are implemented as two elements, a type T and a value V. ... An interface value is nil only if the V and T are both unset.
(内部的に、インターフェースは型 T と値 V の2要素で実装される。……インターフェース値が nil になるのは、T と V の両方が未設定のときだけである)

つまり、インターフェースが nil と等しくなるのは、型も値も両方とも空のときだけ。片方でも埋まっていれば nil ではありません。

今回、validate は戻り値の型が *ValidationError です。だから return nil で返しているのは「*ValidationError 型の nil ポインタ」= (T=*ValidationError, V=nil) です。これを run が error インターフェースに代入した瞬間、型 T に *ValidationError が刻まれます。値 V は nil でも、型が入っているのでインターフェースとしては非 nil になる。これが「nil を返したのに nil じゃない」の正体です。

一方、error を直接 return nil すると、それは (T=nil, V=nil) の「本物の nil インターフェース」になります。A Tour of Go の「Interface values with nil underlying values」「Nil interface values」でも、同じ2要素モデルで説明されています。

図にすると、こういう対比です。

具体型の nil ポインタ    →  (型=*ValidationError, 値=nil)   … err == nil は false
error に直接 nil        →  (型=nil,               値=nil)   … err == nil は true

🔧 直し方:エラーは「最初から error 型」で返す

処方箋はシンプルです。エラーを返す関数の戻り値は、具体型ポインタではなく error インターフェースにする。こうすると、return nil は本物の nil インターフェースになります。

// 戻り値を error にする(*ValidationError にしない)
func validate(name string) error {
	if name == "" {
		return &ValidationError{Msg: "name is empty"}
	}
	return nil // これは (型=nil, 値=nil) の本物の nil
}

validate の戻り値を error に変えるだけで、run("gopher") の err == nil は true に戻ります。同じ考え方は error 以外のインターフェースでも同じです。「具体型で作った値を、途中でインターフェースに詰め替える」設計のときは、どこで詰め替わるかを意識すると事故が減ります。

なお、どうしても具体型で受けた値を返したい事情があるなら、代入の直前で if e != nil { return e }; return nil のように明示的に nil を返し分ける手もあります。ただし基本は「最初から error 型で扱う」のが素直です。

⚠️ 本番のコードで気をつける落とし穴

typed nil まわりで、私がとくに気をつけている落とし穴を3つ挙げます。

落とし穴1:戻り値の型を具体型ポインタにしてしまう

これが根本原因です。「独自エラー型を作ったから、戻り値もその型にしよう」と考えると、この罠に落ちます。エラーは error 型で返すを徹底すれば、typed nil は原理的に発生しません。go vet の nilness 系の検査や staticcheck が拾ってくれる場合もありますが、まず設計で防ぐのが確実です。

落とし穴2:typed nil のメソッド呼び出しで panic する

err(中身は nil の *ValidationError)に対して err.Error() を呼ぶと、Error() の中で e.Msg を参照した瞬間に nil ポインタ参照で panic します。fmt.Println(err) は内部で Error() を呼ぶため、「err を表示しようとしたら落ちる」という二次災害が起きがちです。typed nil を疑うときは、値の表示ではなく %T で型を見るのが安全です。

落とし穴3:== nil は「比較する側の静的な型」で意味が変わる

同じ nil ポインタでも、変数の静的な型が具体型(*ValidationError)なら == nil は素直に true、インターフェース(error)に入った瞬間から (型, 値) 比較になって挙動が変わります。「どの型として nil と比べているか」を意識すると、混乱が減ります。

📋 コピペ保存版:typed nil 切り分けチェックリスト&早見表

手元に置いて、== nil が想定外の挙動をしたら上から当ててください。

□ 1. err == nil / iface == nil が想定と食い違う → typed nil を疑う
□ 2. fmt.Printf("%T %v", x, x) で「動的な型」と「値」を出す
□ 3. 型欄に具体型(*main.XxxError 等)が出て、値が <nil> なら typed nil 確定
□ 4. その値を生んだ関数の戻り値の型を確認(error か? 具体型ポインタか?)
□ 5. 具体型ポインタを返していたら、戻り値の型を error に変える
□ 6. 表示で確認しない(Error() が nil レシーバで panic することがある)。型で見る

x == nil がどうなるかの早見表です。

早見表

太字の行が、この記事の主役です。「インターフェースに具体型の nil ポインタを入れると非 nil になる」——これだけ覚えて帰ってください。

⏱ 所要時間:正体を知る前は数時間、知っていれば5分

体感ベースの目安です。私の手元での内訳を出します。

  • %T で動的な型を出す:約1分

  • 型欄が具体型・値が <nil> と確認する:約1分

  • 値を生んだ関数の戻り値の型をさかのぼる:約2分

  • 戻り値の型を error に直して再実行:約1分

  • 合計:約5分

正体を知らなかった頃の私は、if err != nil の中に fmt.Println を足したり消したりして半日溶かしました。「(型, 値) の2枚組」という原理を1回知れば、次からは %T 一発で5分です。時間差の正体は、手の速さではなく仕組みを知っているかどうかでした。

🧭 おわりに:nil は「値」だけでなく「型」も見て決まる

要点を3行で。

  • インターフェースは「型 T」と「値 V」の2枚組。nil と等しいのは両方とも空のときだけ(Go FAQ)

  • 具体型の nil ポインタをインターフェースに入れると、型が刻まれて非 nilになる(typed nil)

  • 直すのは比較条件ではなく設計。エラーは最初から error 型で返す

このシリーズでは、エラーや nil の詰まりを「どこを見れば正体にたどり着くか」を、Go の仕組みから実演していきます。次回は、並行処理でつまずく context を体系化した「context 完全ガイド」を用意しました。「もうやめていい」を goroutine にどう伝えるか、一緒に地図を描けたらうれしいです。

いいなと思ったら応援しよう!