..

Don't sleep on inline interfaces

READER WARNING: This post may contain ramblings.

Quack..

Go is different than most languages when it comes to interface type equality.

The term for the way it works is structural typing, which bears some resemblance to duck typing. Like duck-typing we just check if an “object” (very deliberate quoting here) supports certain methods. As opposed to nominal typing, where 2 types with the same structure but different names, are distinct.

But unlike duck typing, the definition of these methods are formalized in a type definition, and can be checked at compile time. Duck typing is inherently dynamic.

And arguably Go interfaces across packages are in a way nominative (due to package scoping), which is even further removed from duck typing, but we’ll get to that shortly.

Because despite all that, a quick bit of code illustrates how Go interfaces quack like duck typing:

if duck, ok := maybeDuck.(interface { Quack() }); ok {
  duck.Quack()
}

Yes, that will actually compile, and yes that is actually used out in the wild. It’s an inline (anonymous) interface definition, and you can use it anywhere one would reference a type, including arguments in functions and other interface definitions. That’s definitely something you wouldn’t be able to do with duck typing, but hey. Maybe I just like ducks.

Now I know what you’re thinking: this seems ad-hoc. And you’re not wrong.

Donald And Daisy

There is a case for inline interfaces beyond “I was too lazy to define a named interface.” While Go uses structural typing for interfaces, it also has a key limitation: package scoping.

Consider two packages, donald and daisy, each defining a structurally identical Duck interface:

package donald

type Duck interface {
    Quack() string
}
package daisy

type Duck interface {
    Quack() string
}

In direct usage, these are interchangeable:

package main

import (
    "fmt"
    "donald"
    "daisy"
)

func MakeDuckQuack(d daisy.Duck) {
    fmt.Println(d.Quack())
}

type DonaldDuck struct{}

func (DonaldDuck) Quack() string {
    return "Quack from Donald!"
}

func main() {
    var donaldDuck donald.Duck = DonaldDuck{}
    MakeDuckQuack(donaldDuck) // Works fine
}

MakeDuckQuack expects a daisy.Duck, we pass a donald.Duck. It compiles because both interfaces are structurally identical: Go checks their methods, not their origin.

But this breaks down when you try to mix the types in type definitions:

package main

import (
    "fmt"
    "donald"
    "daisy"
)

type Pond interface {
	Feed(d daisy.Duck)
}

type MyPond struct{}

func (MyPond) Feed(d donald.Duck) {
	fmt.Println(d.Quack())
}

func main() {
	var recorder Pond = MyPond{} // Nope
	var donaldDuck donald.Duck = DonaldDuck{}

	recorder.Feed(donaldDuck)
}
./main.go:41:22: cannot use MyPond{} (value of type MyPond) as Pond value in variable declaration: MyPond does not implement Pond (wrong type for method Feed)
                have Feed(donald.Duck)
                want Feed(daisy.Duck)

Dipping In Complexity

Inline interfaces solve this by their anonymous nature. An inline interface in one package is compatible with a “different” one in a second package, as long as the definition is the same.

Does it matter in the real world? Maybe. To illustrate, we’ll have to complicate things a bit…

The ‘D’ in the infamous ‘SOLID’, most relevantly dictates to “depend in the direction of abstractness”. That is to say, more abstract code should not depend on less abstract code, but the other way around. So instead of something like this:

Diagram

Your dependency graph will look something like this:

Diagram

This requires indirection which is normally accomplished using interfaces and dependency injection (in some form). We may not need an interface to make sure a duck can quack, but we would want to abstract out the technical details of what happens when our duck quacks:

app/ducks.go

package app

type QuackRecorder interface {
	Record(q Duck)
}

type Duck struct {
	Name string
}

func (d Duck) Quack() string {
	return d.Name + " quacks!"
}

type DuckConcert struct {
	qp QuackRecorder
}

func NewDuckConcert(qp QuackRecorder) DuckConcert {
	return DuckConcert{qp: qp}
}

func (dc DuckConcert) Perform(d Duck) {
	dc.qp.Record(d)
}

infra/print.go

package infra

import (
	"fmt"

	"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
)

type LineQuackPrinter struct{}

func (d LineQuackPrinter) Record(s app.Duck) {
	fmt.Println(s.Quack())
}

main.go

package main

import (
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)

func main() {
	c := app.NewDuckConcert(infra.LineQuackPrinter{})
	c.Perform(app.Duck{Name: "Donald"})
}

What we have now is a bastardized version of a typical DIP implementation. It would work similar to this in most languages.

Ducks Can Fly

But go isn’t most languages. I am reminded of one of my son’s favorite stories: Kikker is kikker (frog is frog). Early in the story the protagonist, a frog, and one of his best friends, a duck, compare attributes. To the frogs amazement, the duck can fly. Something the frog has never seen the duck do before. The duck explains she is a bit lazy, but yes, she can fly.

Go can do things you may not have considered, even if you consider it a good friend. We can decouple these packages further, if you’re a bit less lazy.

Diagram

We’re going to completely defer committing to any implementation, to a single site (our main package).

We cannot simply declare an interface in the infra package with the same signature as the app package. This will not work because app.QuackRecorder would expect the app.Quacker interface and not the infra.Quacker interface. It’s the Donald and Daisy problem. While interfaces are structurally typed, package scoping introduces a nominative aspect.

There’s more than 1 way to solve this. Let’s review.

1. Shared abstraction

We can extract the Quacker abstraction to a different package. This is probably the most commonly used solution, but it doesn’t really solve the problem, at least not in the way we want.

type QuackRecorder interface {
	Record(q fourthpackage.Quacker)
}

The app and infra packages are still dependent, just indirectly. We’ve successfully avoided an import cycle, but we’ve kinda muddied the waters. Especially if you like to think of packages as application layers (which you should). Some might argue that in DDD a layer with only abstractions and data is a great implementation, personally I like my layers to actually have behavior. Is a layer that contains no behavior really a “layer”?

Putting aside that tangent for a different time, this is an OK solution. But it doesn’t promote decoupling as much as it could. It complicates our import graph, possibly making our design more prone to import cycle problems in future revisions.

What else can we do?

2. Generics to the rescue

We can sidestep the issue completely. Deferring committing to a specific implementation is where generics shine and that was exactly what we were after in pursuit of decoupling infra from app.

We can use an interface in infra as a constraint, so we can be sure Quack() is available:

infra/print.go

package infra

import (
	"fmt"
)

type Quacker interface {
	Quack() string
}

type LineQuackPrinter[T Quacker] struct{}

func (d LineQuackPrinter[T]) Record(q T) {
	fmt.Println(q.Quack())
}

In app, we don’t really care what the type argument to QuackRecorder is. We could create a local Quacker interface but app is not using Quack() so it would be nonsensical.

app/ducks.go

package app

type QuackRecorder[T any] interface {
	Record(q T)
}

type Duck struct {
	Name string
}

func (d Duck) Quack() string {
	return d.Name + " quacks!"
}

type DuckConcert[T any] struct {
	qp QuackRecorder[T]
}

func NewDuckConcert[T any](qp QuackRecorder[T]) DuckConcert[T] {
	return DuckConcert[T]{qp: qp}
}

func (dc DuckConcert[T]) Perform(d T) {
	dc.qp.Record(d)
}

In main we tie it together:

main.go

package main

import (
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)

func main() {
	c := app.NewDuckConcert(infra.LineQuackPrinter[app.Duck]{})
	c.Perform(app.Duck{Name: "Donald"})
}

This again is an OK solution. Actually I quite like it, generics are great. I’m especially fond of the flexibility of a difference in specificity in type arguments, as noted above.

But they share a trait with “async” in other languages, in that they tend to proliferate. You put one little type argument in one place and before you know it you open a hatch on a space station and are buried in furry little animals.

You can see the beginnings of that in DuckConcert: we had to add a type argument because we couldn’t commit to T of QuackRecorder.

3. Inline interfaces

Last but not least, inline interfaces. We leverage the fact that the nominative aspect of interfaces is removed, specifically when providing cross-package integration points.

app/ducks.go

package app

type QuackRecorder interface {
	Record(q interface{ Quack() string })
}

type Duck struct {
	Name string
}

func (d Duck) Quack() string {
	return d.Name + " quacks!"
}

type DuckConcert struct {
	qp QuackRecorder
}

func NewDuckConcert(qp QuackRecorder) DuckConcert {
	return DuckConcert{qp: qp}
}

func (dc DuckConcert) Perform(d interface{ Quack() string }) {
	dc.qp.Record(d)
}

infra/print.go

package infra

import (
	"fmt"
)

type LineQuackPrinter struct{}

func (d LineQuackPrinter) Record(q interface{ Quack() string }) {
	fmt.Println(q.Quack())
}

main.go

package main

import (
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
	"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)

func main() {
	c := app.NewDuckConcert(infra.LineQuackPrinter{})
	c.Perform(app.Duck{Name: "Donald"})
}

You’ll quickly spot the two main characteristics of this solution:

  1. It’s more terse and simple than the alternatives
  2. It has interface definition duplication

In a nutshell: its appeal depends on how much duplication it would lead to. To paraphrase Rob Pike: a little bit of duplication is better than a lot of dependency. I think this solution exemplifies this.

Leveraging Type Aliases

To reduce the intra-package duplication, we can create an alias of an unnamed interface, and end up with a handle to something that’s not scoped to a package:

package app

// Create alias of `interface { Quack() string }`
type quacker = interface {
	Quack() string
}

type QuackRecorder interface {
	Record(q quacker)
}

...

func (dc DuckConcert) Perform(d quacker) {
	dc.qp.Record(d)j
}

Aside from adding valuable semantics and squeezing out the last bit of intra-package duplication, this is the only viable option when signatures become more complex.

Consider:

func Perform(recorder interface { Record(q interface { Quack() }) }) {
    recorder.Record(Duck{Name: "Donald"})
}

func AnotherFunction(recorder interface { Record(q interface { Quack() }) }) {
    recorder.Record(Duck{Name: "Daisy"})
}

Vs:

type quacker = interface {
    Quack() string
}

type quackRecorder = interface {
    Record(q quacker)
}

func Perform(recorder quackRecorder) {
    recorder.Record(Duck{Name: "Donald"})
}

func AnotherFunction(recorder quackRecorder) {
    recorder.Record(Duck{Name: "Donald"})
}

Suddenly our “anonymous” interface doesn’t seem so ad-hoc anymore.

Overview

To sum up this chapter, here’s a comparison matrix of the 3 solutions:

SolutionStrengthsWeaknesses
Shared AbstractionsReusable definitionsComplicates dependency graph
GenericsSimple dependency graph, appealing safety / flexibility balanceType argument propagation
Inline InterfacesSimple dependency graph and implementation, no propagating side effectsAt least some duplication

Putting Our Ducks In A Row

I’ve long, long abandoned the idea of an “ideal solution”. There’s no such thing, in any aspect of life, and definitely not in programming.

Inline (or more generally, anonymous / unnamed) interfaces are a pragmatic choice to reduce package coupling.

I’m not saying that striving for this level of decoupling is strictly needed. It has benefits though, and while it may seem like it complicates things in some way, in a very real way it simplifies by reducing the complexity of the dependency graph / import tree. Something that would be impossible if you use package-scoped types, whether they be interfaces or concrete types.

It’s not ideal, but it gets the job done. Don’t sleep on it.