Spectral Contexts in Go
posted
If you’re attaching values to Go contexts, you probably do that in a single location like so:
type contextKey string
const (
userIDKey contextKey = "userID"
// etc...
)
func GetUserID(ctx context.Context) UserID {
return ctx.Value(userIDKey).(UserID)
}
// etc...
func (s *Server) WithUserData(ctx context.Context, req http.Request) (context.Context, error) {
ctx = s.withRequestDataLoaders(ctx)
ctx, err := s.withUserAndTeamID(ctx, req)
if err != nil {
return nil, err
}
ctx, err = s.withIPAddress(ctx, req)
if err != nil {
return nil, err
}
ctx = s.withLogger(ctx, req)
return ctx, nil
}
It’s fine, but a bit boilerplatey: You have to define XXXRequestValue
and a
GetXXX
utility function per element. It’s not very boilerplatey, but enough
that I get annoyed by it.
If you’re like me and usually attach a type once, then it’s possible to use phantom types to attach singletons to a context:
package ctxx
import "context"
type ctxKey[T any] struct{}
// WithSingleton attaches val to the context as a singleton.
func WithSingleton[T any](ctx context.Context, val T) context.Context {
return context.WithValue(ctx, ctxKey[T]{}, val)
}
// Singleton returns the single value T attached to the context.
// If there is no value attached, the zero value is returned.
func Singleton[T any](ctx context.Context) T {
val, _ := ctx.Value(ctxKey[T]{}).(T)
return val
}
A phantom type is a type with a type parameter that isn’t used in its type
definition. In this case, ctxKey
is a phantom type because T
isn’t used.
While the binary representation of ctxKey[UserID]{}
and ctxKey[TeamID]{}
is
the same when we know their concrete type, they differ when converting them to
interface values. Interface values are a tuple of type and value under the
covers, which is why
any(ctxKey[UserID]{}) == any(ctxKey[TeamID]{})
is always false, and that’s what we can take advantage of.
With this new ctxx
package, we can attach request values like this
func (s *Server) WithUserData(ctx context.Context, req http.Request) (context.Context, error) {
ctx = ctxx.WithSingleton(ctx, s.newDataLoaders())
userID, teamID, err := s.userAndTeamID(req)
if err != nil {
return nil, err
}
ctx = ctxx.WithSingleton(ctx, userID)
ctx = ctxx.WithSingleton(ctx, teamID)
ipAddr, err := s.findIPAddress(req)
if err != nil {
return nil, err
}
ctx = ctxx.WithSingleton(ctx, idAddr)
// net.IP seems too generic, so I wrap it in a UserIP type
ctx = ctxx.WithSingleton(ctx, s.newLogger(req, userID, teamID))
return ctx, nil
}
and retrieve them like this
userSvc := ctxx.Singleton[UserID](ctx)
which is a bit more handy in my experience.
Context Values are Alright
People have opinions on whether you should add values to your context or not. If I could, I would prefer a typed context like this:
type MyContext struct {
Context context.Context
UserID UserID
TeamID TeamID
UserIP UserIP // (wrapper around net.IP)
// etc
}
This one usually won’t survive through middlewares or external libraries though, as those will wrap the context with cancellations and whatnot, causing the typed context to be lost.
As I’d prefer a typed alternative if possible, I played around with typed contexts in a previous blog post. However, using one gets too unwieldy and heavy to use in practice, and the type won’t survive through external third-party middlewares either.
Attaching context values in a type-unsafe manner is the only way you can really use middleware and third-party libraries effectively, so the question is whether you should or not.
Personally, I have never experienced any issues with attaching and fetching context values, but I think that’s heavily related to how I use them. I never conditionally set context values, and they are mostly used to pass data down through middleware for GRPC/HTTP/GraphQL handlers, not further down the chain.
I think context values are a necessary evil for passing data through parts you don’t control, but as long as you have control you should pass stuff down as parameters.
A previous version of this blog post had services as an example of what you propagate down via contexts. That was silly: There are cases you can’t get around it, but you can typically use struct methods as handlers and fetch the services from struct fields or methods instead.