iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
⛑️

Pitfalls of Interfaces and nil When Storing Values in Go Context

に公開

📝 Introduction

In web services, it's common practice to store request-scoped information (such as authenticated user, tenant, permissions, tracing information, etc.) in context.Context and pass it to each layer.
While convenient, there's a hidden "typed-nil" problem involving interfaces and nil, which can lead to bugs if existence checks are done incorrectly.

Here, taking the "authenticated user (Principal)" commonly used in services as an example, we will summarize:

  • Designing values to store in Context
  • The pitfall of interface × nil
  • Comparison of implementation A/B and a safe alternative
  • Practical coding guidelines

✅ First, explicitly define the interface (Principal)

To avoid the problem of model.Session suddenly appearing, this article uses the authenticated user (Principal) as an example, and first shows the interface definition.

// package auth provides boundaries related to authentication (types and context operations).
package auth

// Principal represents the domain boundary of the "subject executing the request (authenticated user)".
// The caller depends only on this interface, not on the implementation.
type Principal interface {
	UserID() string
	TenantID() string
	Roles() []string
	IsActive() bool
}

Concrete Type (Private)

package auth

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

// NewPrincipal is a factory that generates a concrete 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 }

🔑 Use an "unexported custom type" for the Context Key (Go idiomatic practice)

For context.WithValue keys, use an unexported custom type to prevent conflicts.

package auth

import "context"

type contextKey int

const (
	principalKey contextKey = iota
)

// WithPrincipal stores Principal in context and returns it.
// Do not pass nil (convention). If nil, do not store and return the original ctx, or explicitly return an error.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
	if p == nil { // By rejecting nil here, we prevent "typed-nil" at the source.
		return ctx
	}
	return context.WithValue(ctx, principalKey, p)
}

⚠️ Pitfall of interface × nil (typed-nil) (Crucially Important)

Go's interface values are a "type + value" pair.

var impl *principal = nil
var i Principal = impl

// i becomes a pair of "type = *principal, value = nil"
// Therefore, i == nil is false (the interface itself is non-nil)
_ = (i == nil) // => false

This means that assertion success does not necessarily imply existence, which is a trap. If (x.(Principal)) succeeds, but the content is a typed-nil (*principal)(nil), then i == nil will be false.


📦 Get Implementation A/B and their comparison (returning interface version)

Implementation A (simply return)

// GetPrincipal returns Principal and "assertion success".
func GetPrincipal(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey).(Principal)
	return p, ok
}
  • Advantages
    • Simple and readable
    • Can be reused for value types
  • ⚠️ Disadvantages
    • Typed-nil is still possible even if ok == true
    • Relying on p == nil check by the caller can lead to accidents (a convention requiring ok to be checked is necessary)

Implementation B (return nil,false if not set)

// GetPrincipal returns "nil,false when not set".
func GetPrincipal(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey).(Principal)
	if !ok {
		return nil, false
	}
	return p, true
}
  • Advantages
    • Clearly communicates that it's not set (key mismatch) with nil,false
    • The caller can check with either ok or nil (double safeguard)
  • ⚠️ Disadvantages
    • The typed-nil problem still remains (ok==true but typed nil)
    • Still requires a convention to "use ok as the sole existence check"

Conclusion: For both A/B, thoroughly enforce "always check ok" and do not rely on p == nil.
Furthermore, it is crucial not to insert nil with WithPrincipal.


🛡️ Most secure alternative in practice: Return a concrete type pointer

Returning a concrete type pointer instead of an interface allows you to handle typed-nil completely.

// Publicize the concrete type (or it can be unexported in a separate package)
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 // Convention: do not store 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 // Guarantees "type match" and "non-nil actual value"
}
  • Benefits
    • ok && p != nil reliably dismisses unset values
    • The caller can safely check with if !ok { ... } or if p == nil { ... }
    • Also helps avoid nil receiver panics during method calls

🧩 Example usage in middleware/handler

package httpx

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

// Example of authentication middleware (pseudo-code)
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Example: Resolve the principal from headers or tokens
		p := auth.NewPrincipal("u_123", "t_001", []string{"admin"}, true)

		// Convention: Do not insert nil
		r = r.WithContext(auth.WithPrincipal(r.Context(), p))

		next.ServeHTTP(w, r)
	})
}

// Handler side
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() // Use safely (but do not rely on p==nil check)
	w.WriteHeader(http.StatusOK)
}

If you adopt the concrete type pointer version, use auth.WithPrincipalData / auth.GetPrincipalData similarly.


📐 Team Conventions (Sample)

  • [must] For GetXxx(ctx) return values, use ok as the sole existence check. Do not rely on == nil.
  • [must] Do not store nil with WithXxx(ctx, v). Express unauthenticated states by "not storing".
  • [should] Prioritize returning concrete type pointers. If returning an interface is unavoidable, understand the danger of typed-nil and verify ok checks during review.
  • [should] Use unexported custom types for Context keys.
  • [should] Consider designing "not found" to return (nil,false) or (_,false) instead of an error (to prevent log pollution).

✅ Summary

  • Context is convenient, but the interface × nil "typed-nil" problem lurks.
  • The basic principle is to check ok for GetXxx and not to store nil.
  • The safest approach is to return a concrete type pointer (ok && p != nil).
  • Establish patterns for storage in middleware and retrieval in handlers, and share them as team conventions.

Appendix: Comparison Table of A/B Implementations (when returning an interface)

Perspective A: Simply Return B: Not Set is nil,false
Readability
Representation of Not Set (_,false) (nil,false)
Typed-nil Avoidance × (remains) × (remains)
Caller Check ok required ok required
Generality (value type reuse)
Recommendation Level

Fundamentally, leaning towards "concrete type pointer" is safer.


Discussion