⛑️

GoでContextに値を格納するときに潜むinterfaceとnilの落とし穴

に公開

📝 はじめに

Web サービスでは、リクエストスコープの情報(認証ユーザー、テナント、権限、トレーシング情報など)を context.Context に積んで各レイヤに渡すのが一般的です。
便利な一方で、interface と nil が絡む「typed-nil」問題が潜んでおり、存在判定を誤るとバグにつながります。

ここでは サービスでよく使われる「認証ユーザー(Principal)」 を例に、

  • Context に格納する値の設計
  • interface × nil の落とし穴
  • 実装 A/B の比較と安全な代替案
  • 実務向けのコード規約

をまとめます。


✅ まずインターフェース定義を明示する(Principal)

「急に model.Session が出てくる」問題を避けるため、この記事では 認証ユーザー(Principal) を例にとり、最初にインターフェース定義を示します。

// package auth は認証関連の境界(型とコンテキスト操作)を提供する。
package auth

// Principal は「リクエストを実行している主体(認証ユーザー)」を表すドメイン境界。
// 呼び出し側はこのインターフェースだけに依存し、実装に依存しない。
type Principal interface {
	UserID() string
	TenantID() string
	Roles() []string
	IsActive() bool
}

具体型(非公開)

package auth

type principal struct {
	userID   string
	tenantID string
	roles    []string
	active   bool
}

// NewPrincipal は Principal の具象を生成するファクトリ。
func NewPrincipal(userID, tenantID string, roles []string, active bool) Principal {
	return &principal{
		userID:   userID,
		tenantID: tenantID,
		roles:    append([]string(nil), roles...),
		active:   active,
	}
}

func (p *principal) UserID() string   { return p.userID }
func (p *principal) TenantID() string { return p.tenantID }
func (p *principal) Roles() []string  { return append([]string(nil), p.roles...) }
func (p *principal) IsActive() bool   { return p.active }

🔑 Context Key は「未公開の専用型」を使う(Go の定石)

context.WithValue のキーには、衝突防止のため未公開の専用型を使います。

package auth

import "context"

type contextKey int

const (
	principalKey contextKey = iota
)

// WithPrincipal は Principal を context に格納して返す。
// nil を渡さないこと(規約)。nil の場合は格納せず元の ctx を返すか、明示的にエラーにする。
func WithPrincipal(ctx context.Context, p Principal) context.Context {
	if p == nil { // ここで nil を弾くことで "typed-nil" を源流で防ぐ
		return ctx
	}
	return context.WithValue(ctx, principalKey, p)
}

⚠️ interface × nil(typed-nil)の落とし穴(超重要)

Go のインターフェース値は 「型 + 値」 のペアです。

var impl *principal = nil
var i Principal = impl

// i は「型= *principal, 値=nil」のペアになる
// よって i == nil は false(インターフェース自体は非 nil)
_ = (i == nil) // => false

つまり 「アサーション成功したから存在する」とは限らない のが罠です。
(x.(Principal)) が成功しても、中身が (*principal)(nil)typed-nil だと i == nilfalse になります。


📦 取り出し実装 A/B とその比較(interface を返す版)

実装 A(シンプルに戻す)

// GetPrincipal は Principal と「アサーション成否」を返す。
func GetPrincipal(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey).(Principal)
	return p, ok
}
  • メリット
    • シンプルで読みやすい
    • 値型でも流用可能
  • ⚠️ デメリット
    • ok == true でも typed-nil の可能性が残る
    • 呼び側が p == nil 判定に頼ると事故る(必ず ok を見る規約が必要)

実装 B(未設定時は nil,false を返す)

// GetPrincipal は「未設定時に nil,false」を返す。
func GetPrincipal(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey).(Principal)
	if !ok {
		return nil, false
	}
	return p, true
}
  • メリット
    • 未設定(キー不一致)を nil,false で明確に伝えられる
    • 呼び側は ok でも nil でも判定できる(二重の安全網)
  • ⚠️ デメリット
    • typed-nil 問題は依然として残るok==true かつ型付き nil)
    • やはり「ok を唯一の存在判定にする」規約が必要

結論:A/B どちらでも「必ず ok を見る」 を徹底してください。p == nil に依存しない。
さらに WithPrincipal で nil を入れない ことが重要です。


🛡️ 実務で最も安全な代替:具体型ポインタを返す

interface ではなく 具体型ポインタ を返すと、typed-nil を完全に扱えます。

// 具体型を公開(または別パッケージで非公開でも可)
type PrincipalData struct {
	UserID   string
	TenantID string
	Roles    []string
	Active   bool
}

func (p *PrincipalData) Clone() *PrincipalData {
	if p == nil { return nil }
	cp := *p
	cp.Roles = append([]string(nil), p.Roles...)
	return &cp
}

type contextKey2 int
const principalDataKey contextKey2 = iota

func WithPrincipalData(ctx context.Context, p *PrincipalData) context.Context {
	if p == nil {
		return ctx // 規約として nil は格納しない
	}
	return context.WithValue(ctx, principalDataKey, p)
}

func GetPrincipalData(ctx context.Context) (*PrincipalData, bool) {
	p, ok := ctx.Value(principalDataKey).(*PrincipalData)
	return p, ok && p != nil // 「型一致」かつ「実体が non-nil」を保証
}
  • 利点
    • ok && p != nil未設定を確実に弾ける
    • 呼び側は if !ok { ... } でも if p == nil { ... } でも安全に判定可能
    • メソッド呼び出し時の nil レシーバ・パニックも避けやすい

🧩 ミドルウェア/ハンドラでの利用例

package httpx

import (
	"net/http"
	"github.com/yourorg/yourapp/auth"
)

// 認証ミドルウェアの例(擬似コード)
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 例: ヘッダやトークンから主体を解決
		p := auth.NewPrincipal("u_123", "t_001", []string{"admin"}, true)

		// 規約: nil は入れない
		r = r.WithContext(auth.WithPrincipal(r.Context(), p))

		next.ServeHTTP(w, r)
	})
}

// ハンドラ側
func HandleMe(w http.ResponseWriter, r *http.Request) {
	p, ok := auth.GetPrincipal(r.Context())
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	_ = p.UserID() // 安全に利用(ただし p==nil 判定に頼らない)
	w.WriteHeader(http.StatusOK)
}

具体型ポインタ版を採用するなら auth.WithPrincipalData / auth.GetPrincipalData を同様に使ってください。


📐 チーム規約(サンプル)

  • [must] GetXxx(ctx) の戻り値は、ok を唯一の存在判定として使用する。== nil に頼らない。
  • [must] WithXxx(ctx, v)nil を格納しない。未ログイン等は「格納しない」ことで表現する。
  • [should] 返り値は 具体型ポインタ を優先する。やむを得ず interface を返す場合は、typed-nil の危険性を理解し、レビューで ok チェックを確認する
  • [should] Context key は 未公開の専用型 を使う。
  • [should] 「存在しない」はエラーではなく (nil,false) または (_,false) で返す設計を検討(ログ汚染防止)。

✅ まとめ

  • Context は便利だが、interface × nil の「typed-nil」問題が潜む。
  • GetXxxok を見ること、nil を格納しないことが基本線。
  • 最も安全なのは 具体型ポインタで返す設計(ok && p != nil)。
  • ミドルウェアでの格納/ハンドラでの取得パターンを整備し、規約としてチームに共有しよう。

付録:A/B 実装の比較表(interface を返す場合)

観点 A: シンプルに返す B: 未設定は nil,false
読みやすさ
未設定の表現 (_,false) (nil,false)
typed-nil回避 ×(残る) ×(残る)
呼び側の判定 ok 必須 ok 必須
汎用性(値型流用)
推奨度

根本的には「具体型ポインタ」に寄せるのが無難


Discussion