Variadic Type Parameters and Tuples for Go ๐Ÿ’ก

ยท

5 min read

Presently in Go, functions can return multiple values. As a convention, functions that return errors place the error in the last return value. The syntax for multiple returns resembles tuples from Python and other languages, at first glance. But they're not tuples. The values are returned separately rather than as one value that can be destructured. This creates two problems:

  1. Functions of different arities cannot be composed without an intermediate anonymous function. This is much like the "what colour is your function?" problem: just as we can't compose sync functions and async functions in JavaScript (without going all async in the result), we can't compose function with different arities or return types.
  2. Common error handling cannot be abstracted into a function. This follows directly from (1).

Here is an example to demonstrate these problems.

func Foo(a int, b string) (int, error) {
    return 0, nil
}

func Bar(x int) int {
    return x + 1
}

var FooThenBar = funcs.AndThen(Foo, Bar) // We cannot do this

How would we implement AndThen? We cannot even express it in a general way, despite having generics. The goal here is to combine functions of arbitrary arities and return values, breaking if any of them returns a non-nil error. For predefined numbers of arguments and return values, this is possible as follows.

// This would go on a type if generic methods could introduce type parameters.
package funcs

func AndThen[A, B, C any](
    runA func(A) (B, error),
    runB func(B) (C, error),
) func(A) (C, error) {
    return func(a A) (C, error) {
        var cZero C
        b, err := runA(a)
        if err != nil {
            return cZero, err
        }
        return runB(b)
    }
}

func AndThen2... // ew
func AndThen3... // nope!

You can see that we have a composition problem. What would a solution look like?

Native Tuples

The first piece of the solution is to make the multiple return values something more than a wannabe tuple. Maybe an actual tuple. This means that we should be able to write:

    package main

    type Netflix struct {}
    type Chill struct {}

    var NetflixAndChill (Netflix, Chill)

    func main() {
        NetflixAndChill = (Netflix{}, Chill{})
        haveAGoodTime(NetflixAndChill)
        haveAnotherGoodTime(NetflixAndChill)
        netflix, chill = NetflixAndChill
    }

    func haveAGoodTime(n Netflix, c Chill) {}
    func haveAnotherGoodTime(nc (Netflix, Chill)) {}

The snippet above shows some concerns for composition. Currently in Go, we can pass the result of a function with multiple return values directly to a function that accepts exactly those types in-order. This has to be maintained for backwards compatibility. Fortunately this is possible if we treat multiple return values as tuple destructuring.

Variadic Type Parameters

With tuples in place, we need one more feature to write AndThen in a general way. Remember that the idea is to compose functions that return a tuple with an error as the last value of the tuple. Suppose we could write the following.

    package funcs

    func AndThen[ParamsA ...any, ParamsB ...any, ReturnC ...any](
        runA func(ParamsA...) (ParamsB..., error),
        runB func(ParamsB...) (ReturnC..., error)
    ) func(ParamsA...) (ReturnC..., error) {
        return func(paramsA ParamsA...) (ReturnC..., error) {
            var retC (ReturnC...)
            paramsB, err := runA(paramsA...)
            if err != nil {
                return (retC..., err)
            }
            return runB(paramsB...)
        }
    }

The parts in this snippet are the same as that we defined earlier, except at the type level. Here's what it says:

  1. We expect a set of types, and they can be any type. We call them ParamA. Similarly for ParamB and ReturnC. Notice how the ... goes before the any in the type parameters, just as in variadic functions. The symmetry is intentional.
  2. We apply the parameters ParamsA, ParamsB, and ReturnC in tuples to produce tuple types containing the types in these parameters. The substitution will be done by the compiler before type checking. Notice the syntax for spreading a tuple into a function call, or into another tuple; it's similar to how we spread a slice in append(xs, ys...).
  3. If type checking succeeds, now have a function specialised to its arguments functions, whatever their arities.

When we apply the above definition of AndThen at a call site, it will be specialised as follows.

    func Foo(a int, b string) (int, error) {
        return 0, nil
    }

    func Bar(x int) int {
        return x + 1
    }

    var FooThenBar = funcs.AndThen(Foo, Bar)

From the argument Foo, the type parameter ParamsA is inferred as (int, string). ParamsB is inferred as (int) since Foo's return type, (int, error) matches (ParamsB..., error). AndThen` becomes:

    func AndThen[(int, string), (int), ReturnC ...any](
        runA func((int, string)...) ((int)..., error),
        runB func((int)...) (ReturnC..., error)
    ) func((int, string)...) (ReturnC..., error) {
        return func(paramsA (int, string)...) (ReturnC..., error) {
            var retC (ReturnC...)
            paramsB, err := runA(paramsA...)
            if err != nil {
                return (retC..., err)
            }
            return runB(paramsB...)
        }
    }

Now we apply the substitution to the return type of runB and reduce ReturnC to (int). AndThen becomes:

    func AndThen[(int, string), (int), (int)](
        runA func((int, string)...) ((int)..., error),
        runB func((int)...) ((int)..., error)
    ) func((int, string)...) ((int)..., error) {
        return func(paramsA (int, string)...) ((int)..., error) {
            var retC ((int)...)
            paramsB, err := runA(paramsA...)
            if err != nil {
                return (retC..., err)
            }
            return runB(paramsB...)
        }
    }

Finally, we evaluate the type spreads to obtain tuple types for the specialised function. AndThen becomes:

    func AndThen(
        runA func(int, string) (int, error),
        runB func(int) (int, error)
    ) func(int, string) (int, error) {
        return func(paramsA1 int, paramsA2, string) (int, error) {
            var retC (int)
            paramsB1, err := runA(paramsA1, paramsA2)
            if err != nil {
                return (retC..., err)
            }
            return runB(paramsB1)
        }
    }

Notice that we spread retC into runB since its type was replaced with (int).

Our final definition of AndThen looks almost the same as what we started out with (without generics). By combining tuples with variadic type parameters, we obtain a powerful facility for composing functions of different shapes easily.

A Little Syntax Bike-shedding

With this approach, we have to nest calls to AndThen to make a chain of functions that will be applied together. Such nesting would look better as method calls than as function calls. That is, it'd be better to write

    type MayError[Arguments ...any, Returns ...any] func((Arguments...)) (Returns..., error)

    var FooThenBarThenBaz = MayError(Foo).
        AndThen(Bar).
        AndThen(Baz).
        AndThen(Quux)

instead of

    var FooThenBarThenBaz = AndThen(AndThen(AndThen(Foo, Bar), Baz), Quux)

This isn't a limitation of the idea presented here. Go currently doesn't allow method definitions to introduce type parameters. If this restriction is lifted, we can write the calls in fluent style. Now I'm asking for three things:

  1. Allow new type parameters on methods;
  2. Add a built-in tuple type; and
  3. Add support for variadic type parameters.

Is that too much to ask for?