hyPiRion

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.