⛑️

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

に公開

📝 はじめに

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

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

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

をまとめます。


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

実例として 認証ユーザー(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"

// Context key は未公開のゼロサイズ型を用いると、衝突回避と意図の明確化ができます。
// メモリ面でも追加のペイロードを持たず、実務上の差はごく小さいものの分かりやすさの利点があります。
type principalKeyType struct{}
var principalKey principalKeyType

// WithPrincipal は Principal を context に格納して返す。
// 規約として nil を格納しない。ただし interface の性質上ここでは typed-nil を検知できないため、呼び出し側で
// NewPrincipal 等により non-nil を渡すポリシーを徹底する。
func WithPrincipal(ctx context.Context, p Principal) context.Context {
 if p == 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(シンプルに戻す)

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

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

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

結論:A/B どちらでも「必ず ok を見る」 を徹底する(p == nil に依存しない)。
さらに WithPrincipal で nil を入れない ことが重要。ただし interface の性質上、WithPrincipal 単体では typed-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.PrincipalFromContext(r.Context())
 if !ok {
  http.Error(w, "unauthorized", http.StatusUnauthorized)
  return
 }
 _ = p.UserID() // 利用前提: ミドルウェアで non-nil を格納(typed-nil を許容しない設計/レビューを徹底)
 w.WriteHeader(http.StatusOK)
}

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


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

  • [must] XxxFromContext(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」問題が潜んでいます。
  • GetXxx では ok を見ること、nil を格納しないことが基本方針です。
  • 最も安全なのは 具体型ポインタで返す設計です(ok && p != nil)。
  • ミドルウェアでの格納/ハンドラでの取得パターンを整備し、規約としてチームに共有しましょう。

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

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

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


Discussion