hyPiRion

Type-Safe HTTP Servers in Go via Generics

posted

Go’s type parameter proposal is being implemented right now. According to the proposal, it is expected to come out with Go 1.18 in early 2022. And with it, we’ll probably see an explosion of libraries and experiments trying to use generics for things left and right. (I hope the community will discover that not everything is a nail after some time.)

I actually did some experimentation myself with the proposal 2 years ago. The result of that is a blog post that discussed a potential way the proposal – at the time – could be extended to support row polymorphism. The most interesting use case for me would be to incrementally create type-safe contexts via middleware functions.

The revised proposal kind of supports this in theory. The proposal and implementation support embedding type parameter methods into a struct, so you can implement structs that take in any type and attaches a new value to them. But due to several complicating factors as described in issue #43621 on GitHub, it seems unlikely that this will hit with 1.18. The go2go playground allows it to some extent, as you can see in this snippet. However, the go2go playground implementation is drifting further and further away from the real deal. You’re better off using gotip for experimentation, which runs master and gives you generics out of the box.

Type-Safe HTTP Servers?

The reason I experimented with row polymorphism was to see if one could make truly type-safe HTTP servers. And it turns out this is possible with Go’s generics! If you’re only interested in the code, you can head over to my working implementation on GitHub. There are some code comments in the repo, but the intricate details and gotchas that I uncovered by implementing it are covered here.

Now, some would already consider Go to be type-safe (ish) right now, even without generics. With code generators for Swagger or GraphQL or some other API specification, you’ll end up having the essentials provided: A typed request parameter, a typed request response, and some way of passing errors. What I haven’t seen in any of those is the ability to make the context typed.

It is possible to bypass this to some extent by attaching databases and other “static” values with struct methods. But it’s not sufficient for request-specific values: User IDs, team-specific caches, request loggers, and so on must all be created and attached to something in a concurrency-safe manner. Middlewares are the only thing able to help out with that without too much boilerplate.

Or well, it is technically possible to do this with a lot of reflection. However, it’s hard and error-prone to develop, and it’s only verifiable when you start the program. With generics, we can make it less error-prone and solve it at compile time.

But using generics to make type-safe contexts does require a slight shift in how values are attached to the context, at least right now. When using contexts, you can and will attach the context values to the context incrementally, as that’s permitted. It’s not impossible to do the same with type parameters, but without structs able to embed type parameters, it’s more effort than it’s worth. So instead, we’ll have to make do with one middleware function attaching all our values in one go. This is a little less flexible than we’d like in theory, but in practice, it doesn’t matter as much: Everyone usually applies all the context-enriching middleware functions on all their endpoints, and then only use the things they need in different situations.

So, what would a typed context even look like? Let’s consider the case where we want a type-safe user ID and a logger instance. The implementation with the least overhead would look like this:

type UserID int64

type MyAppContext struct {
  context.Context
  UserID UserID
  Logger logrus.FieldLogger
}

var _ context.Context = MyAppContext{}

By embedding the context interface, this struct automatically implements context.Context itself. But without generics, this doesn’t make that much sense for two reasons:

  1. Context functions like context.WithDeadline and friends return a context object which is not going to be of the input type you provided. It will be an internal implementation of context.Context inside the context package.
  2. Types carrying context values – for example http.Request – stores them as context.Context, not of the concrete type you sent in.

With generics, we can “solve” both of these problems.

Type-Preserving Context Functions

The context library implements context functions like WithTimeout and WithValue by taking the provided context and wrapping it in a new context. This is necessary if we want to replace existing timeouts, deadlines or values without mutably modifying other contexts. But if we want to have a context type with typed values, we must keep the typed version at the very top.

The solution I think is the most promising is to expose the underlying context value from the typed version, both as a getter and as an “immutable setter”. We can capture that with this generic interface:

type TypedContext[T any] interface {
  context.Context
  InnerContext() context.Context
  WithInnerContext(context.Context) T
}

If there was a “self” keyword in Go that would refer to the implementor type, we wouldn’t need the typed parameter T here. But as nothing like that exists or has been officially proposed, we have to add a type parameter like this.

Since there are no restrictions on T, our implementation could in theory return a different concrete type. It doesn’t make sense here though, so we will usually pass in T as the implementor type itself.

Here’s the implementation of that interface for our MyAppContext type, along with a type check to verify that it does implement the interface.

func (mac MyAppContext) InnerContext() context.Context {
  return mac.Context
}

func (mac MyAppContext) WithInnerContext(ctx context.Context) MyAppContext {
  // pass-by-value makes mac a copy instead of mutating the input parameter
  mac.Context = ctx
  return mac
}

var _ TypedContext[MyAppContext] = MyAppContext{}

Given this wrapped context interface, we can now drag out the inner context and put back the updated context inside whatever wraps it. For example, here’s the WithCancel function for typed contexts:

package typedcontext

func WithCancel[T any](typed TypedContext[T]) (T, context.CancelFunc) {
  ctx, cancel := context.WithCancel(typed.InnerContext())
  return typed.WithInnerContext(ctx), cancel
}

The other context functions would look like this as well.

If the typed context also implements context.Context (e.g. by exposing the context value as I do here), we have a context value with typed values we can update and attach more non-type safe values to. That only leaves us with the problem of types carrying context values.

Carrying Typed Contexts, Take 1

Adding a concrete interface type to a struct works as specified in the Go generics proposal:

type Request[T context.Context] struct {
  ctx T
}

It’s tempting to attempt to make a method on the request to be able to change the concrete context à la http.Request’s WithContext function:

func (r *Request[T]) WithContext[U context.Context](ctx U) *Request[U] {
  var r2 Request[U]
  // Copy over all the fields here
  r2.ctx = ctx
  return &r2
}

… however, methods cannot have type parameters, so this is not valid. There is an explanation of the reason for that in the proposal: The gist of it is that it’s tricky to get it working with interfaces.

Instead of using a method, we must make a function that takes the request and context as parameters:

func WithContext[T,U context.Context](r *Request[T], ctx U) *Request[U] {
  r2 := &Request[U]{}
  // Copy over all the fields here
  r2.ctx = ctx
  return r2
}

But while these code changes are small, getting the standard library over to concrete context types seems extremely unlikely: It will break backwards compatibility if replaced, and the efforts of maintaining two types of the same thing just isn’t worth it. So what can we do instead?

Carrying Typed Contexts, Take 2

Most applications with many endpoints don’t directly implement the http.Handler interface. With a lot of request-response endpoints, you’d like to abstract away the common logic, which tends to be input parsing, writing output and handling errors. As mentioned, that tends to be done via code generators for Swagger or GraphQL (and GRPC if we permit things outside of HTTP).

Assuming we work with interfaces and libraries outside the standard library, the path of moving to typed contexts is less troublesome: We can either implement it ourselves, or hope that bigger frameworks do the move in some fashion. They can either do it in a major version release, or as an identical API in a different subpackage.

In either case, we can assume that a usual HTTP request handler has the following signature (or something similar to it):

package typedhttp

type Handler[In, Out any, Ctx context.Context] interface {
	func(Ctx, In, *http.Request) (Out, error)
}

Although the body from the *http.Request is already read into In, things like the URL or HTTP headers may be of value to you as a downstream consumer. And whereas the context type can be provided via the HTTP request in 1.17, we have to split it out to get it typed as mentioned above.

Middlewares, Sort of

The next logical step would seem to be to make a middleware type:

package typedhttp

type Middleware[Ctx1 context.Context, In1, Out1 any,
                Ctx2 context.Context, In2, Out2 any]
   func (Handler[Ctx1, In1, Out1]) Handler[Ctx2, In2, Out2]

…however, my experiments trying to properly call generated middleware functions caused internal compiler errors with gotip as of this writing.

Even if this would’ve worked, I’m not entirely sold on this anyway: You’ll have to make functions that return functions that themselves return functions, which is unnecessary overhead in this case. It has its uses if the middleware types are identical, because you could put them in lists and chain them. But the entire point of this little exercise is to make it possible to make them return different things!

So instead of making a middleware type, we’ll make functions that take in the input parameters and the handler at the same time, then return the desired handler. Let’s begin with a SetHandlerTimeout function that takes any context type there is, applies a timeout to it, then returns a context.Context to the next handler:

import (
  // ...
  th "path/to/typedhttp"
)

func UntypedSetHandlerTimeout[Ctx context.Context, In, Out any]
   (timeout time.Duration, h th.Handler[context.Context, In, Out])
                             th.Handler[Ctx, In, Out]{
  return func(ctx Ctx, in In, r *http.Request) (Out, error) {
    newCtx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()
    return h(newCtx, in, r)
  }
}

(I’m putting the function signature over 3 lines here, as they get pretty unwieldy with all the typed parameters!)

If you’re not familiar with middleware functions, it will look a bit wonky that the input handler takes the context.Context, whereas the output has the concrete Ctx type. This is a peculiar thing that happens when you operate on functions that take functions as input and then return other functions: Your “output” will be the input of the functions you call, and the result is a function that takes your input as input.

We’re not here for a function that passes a context.Context downstream though, we’re here for a middleware function that preserves the context type. If we ensure that the type we call WithTimeout on is a “typed context”, we can return the output of the typed context, like so:

import (
  // ...
  tc "path/to/typedcontext"
  th "path/to/typedhttp"
)

func SetHandlerTimeout[Ctx context.Context, In, Out any]
  (timeout time.Duration, h th.Handler[Ctx, In, Out])
                            th.Handler[tc.TypedContext[Ctx], In, Out]{
  return func(ctx tc.TypedContext[Ctx], in In, r *http.Request) (Out, error) {
    newCtx, cancel := tc.WithTimeout[Ctx](ctx, timeout)
    defer cancel()
    return h(newCtx, in, r)
  }
}

Here, we ensure that the input to the handler we return is a typed context returning a typed context Ctx, and then we send it to the underlying handler. And as long as the typed context we pass in returns itself, we should be good, right?

The Curse of No Contravariance

It turns out this function signature is not sufficient. You’ll end up with errors like this one:

# github.com/hypirion/typed-http-server-in-go/cmd/server
./server.go:24:75: incompatible type: cannot use withTimeout
 (variable of type Handler[TypedContext[MyAppContext], In, Out]) 
 as Handler[MyAppContext, In, Out] value

It’s not entirely clear at first sight what’s wrong. I find it a bit easier to understand if we replace the [typedhttp.]Handler type with its underlying type. In that case, the error message would rather look like this:

# github.com/hypirion/typed-http-server-in-go/cmd/server
./server.go:24:75: incompatible type: cannot use withTimeout
 (variable of type func (TypedContext[MyAppContext], In, *http.Request) (Out, error))
 as func(MyAppContext, In, *http.Request), (Out, error) value

So the problem seems to be that we have a function named withTimeout that takes in a TypedContext[MyAppContext] as its first argument, but Go expects a function where the first argument is MyAppContext instead.

Recall that we ensured that MyAppContext implements the interface TypedContext[MyAppContext], we even had a sanity check for it:

var _ TypedContext[MyAppContext] = MyAppContext{}

So what gives? If we have a function that can take in any type that implements TypedContext[MyAppContext], then we can take in all possible values of MyAppContext, no?

The problem we’re seeing is that Go omits co- and contravariance of function parameters. In short, the function type parameters must be identical for it to work, and since TypedContext[MyAppContext] is more general than MyAppContext, we can’t use the function.

Tying the Knot

The easiest way to make type parameters identical in this situation would be to ensure that the interface implementor returns something of the type it has itself. However, making the type parameters identical while still retaining genericity is unfortunately not something we can do with interfaces. The only way I’ve been able to implement this is by explicitly tying the knot in the middleware function types.

Functions with type parameters can make use of the defined type parameters in other parameters. But they can also use themselves recursively! For example, consider this Plusser interface1, along with an implementation on an Int type:

type Plusser[T any] interface {
  Plus(T) T
}

type Int int

func (a Int) Plus(b Int) Int {
  return a + b
}

var _ Plusser[Int] = Int(0)

If we wanted to implement a Concat function, we must ensure that whatever type we receive also returns the same type. The only way we can do that is by passing in the type argument we’re defining into itself:

//           this one ↓
func Concat[T Plusser[T]](xs []T) T {
  val := xs[0] // panic if slice is empty
  for _, x := range xs[1:] {
    val = val.Plus(x)
  }
  return val
}

Our SetHandlerTimeout function will do the same, so that the input handler and the output handler gets identical type parameters. It’s the exact same principle as the Plusser example above, but all the other type parameters and arguments make it hard to see the actual change:

import (
  // ...
  tc "path/to/typedcontext"
  th "path/to/typedhttp"
)

func SetHandlerTimeout[Ctx tc.TypedContext[Ctx], In, Out any]
    (timeout time.Duration, h th.Handler[Ctx, In, Out])
                              th.Handler[Ctx, In, Out]{
  return func(ctx Ctx, in In, r *http.Request) (Out, error) {
    newCtx, cancel := tc.WithTimeout[Ctx](ctx, timeout)
    defer cancel()
    return h(newCtx, in, r)
  }
}

If Go’s functions weren’t invariant, this change would make SetHandlerTimout less general. However, because functions are invariant, it just makes the output different, and likely a source of confusion here and there.

From General to Specific

After all of this, now comes the easy part: Implementing a middleware function that takes an arbitrary context and returns a typed one.

func AttachUserAndLogger[Ctx context.Context, In, Out any]
     (h th.Handler[MyAppContext, In, Out])
        th.Handler[Ctx, In, Out] {
  return func(ctx Ctx, in In, req *http.Request) (Out, error) {
    // implementation here
  }
}

Well, the entire thing is a bit verbose, so I’ll point you to the relevant lines over at GitHub. But see that the type parameters and input/output types should be somewhat familiar now!

Note that we don’t actually need to generalise the output function’s context type for our code to work. But because of the invariant issues mentioned in the previous section, it’d be nice of us to let users specify exactly what kind of context type they want as input argument, as a way to convert the output function to the proper type.

… and that’s it! Again, the entire working implementation is over at GitHub, so feel to take a look and poke around.

The Bitter Aftertaste

If you’ve gotten this far, you’ve probably noticed a couple of things that will sting once generics are out. At least some things will be annoying for me, so let’s go through it:

No Method Calls with Additional Type Parameters

Not being able to type parameters onto methods is probably the thing that will immediately stand out. Two things are iffy with it:

First, we won’t be able to pass in a generic data structure and apply a transformation on it. Something like doing Map over a list forces us to know the concrete list type. That means we can’t generalise such functions over linked lists and slices, for example.

I don’t think that the lack of generality will be a problem in practice. For one, many other functional languages (OCaml, Elm) have List.Map or something similar that you have to call, not a function atop the type itself.

But those languages have a significant advantage: They provide or allow these functions to be easily chained. Consider this Java code that sums up the triple of all odd numbers in a list:

myStream.filter(x -> x % 2 == 1)
        .map(x -> x * 3)
        .reduce(0, (a, b) -> a + b);

The equivalent in Go for any list-like datatype will probably look like this, because chaining the operations won’t be possible:

filtered := mySlice.Filter(func (x int) bool { return x % 2 == 1 })
mapped := slice.Map(filtered, func (x int) int { return x * 3 })
total := slice.Reduce(mapped, func (a, b int) int { return a + b })

This isn’t entirely fair to Go, because Java makes it possible to implement Map and Reduce on classes. But languages like Elm and OCaml have a |> operator in hand to make this chaining easier to read:

val = [1, 2, 3]
    |> List.filter (\n -> modBy 2 n == 0)
    |> List.map (\n -> n * 3)
    |> List.foldr (+) 0

Without any |> operator from languages like Elixir, OCaml, Elm or similar languages, it seems unlikely Go will be able to preserve this chain of calls on collections.

Function Signatures must be Identical

I think the bigger problem with the current generic implementation is that function signatures must be identical: A function that returns a slice of ints cannot be used where you demand a function that returns a slice of anything.

Mind you, this is no different from how Go works today. The problem is that it will be more pronounced with type parameters that are “identical” to their input. For example, what’s the difference between these two functions?

func DoSomething1(ctx context.Context) {}

func DoSomething2[T context.Context](ctx T) {}

Well… they are identical and not at the same time! If you set T to context.Context they get the same signature. If you set T to anything else, i.e. something more concrete, they are different – even if DoSomething2 cannot do anything with that information.

It’s not that it doesn’t make sense when you know the rules and the underlying technical reason, it’s the fact that it feels weird before you know them.

Functional Programming: Death by a Thousand Mutable Cuts

I’d argue that none of these issues are major, but they both make it inconvenient to attempt to program in a more functional style. That is no surprise: Even though functions are values in Go, Go has never really attempted to support a very functional programming style. It just borrows from the paradigm in cases where it’s convenient, which in the past has been where flexibility isn’t as important. It’ll be interesting to see how much more the Go community will attempt to incorporate with generics added to the language, but it’ll certainly not be at a level where Haskellers will feel joy.

That being said, I’m certain these limitations are here only because of performance reasons or because of concerns about ambiguity: The Go authors have done a good job at that in the past, and I think the current proposal and upcoming implementation reflect the best implementation they could conceive of without a breaking change.

  1. The entire code for this can be found in the package plusexample