iTranslated by AI
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 == nilcheck by the caller can lead to accidents (a convention requiringokto be checked is necessary)
-
Typed-nil is still possible even if
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
okornil(double safeguard)
- Clearly communicates that it's not set (key mismatch) with
- ⚠️ Disadvantages
-
The typed-nil problem still remains (
ok==truebut typed nil) - Still requires a convention to "use
okas the sole existence check"
-
The typed-nil problem still remains (
Conclusion: For both A/B, thoroughly enforce "always check
ok" and do not rely onp == 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 != nilreliably dismisses unset values - The caller can safely check with
if !ok { ... }orif 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.GetPrincipalDatasimilarly.
📐 Team Conventions (Sample)
-
[must] For
GetXxx(ctx)return values, useokas 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
okchecks 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
okforGetXxxand 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