Go Tip: Creating Closed Interfaces

Controlling implementations of an interface

  • 6 minutes to read

In go , interfaces are satisfied by any type that implements all of the methods defined in the interface. This makes Go interfaces “open” types, meaning that any type can satisfy an interface at any time.

However, did you know that Go actually has a mechanism for restricting who can implement these types? This go-tip will tell you how you can accomplish this.

Goal

To control who can implement an interface, rather than allowing any type to implement it.

Background

In many programming languages, interfaces need to be implemented in some explicit way:

  • Languages like Java or C# have dedicated keywords like implements to declare that a class implements an interface,
  • Languages like Rust have impl for trait bounds
  • Languages like C++ have virtual functions, and are implemented by specific syntax,
  • etc.

Effectively, implementation is an “opt-in” process in many languages.

Go, on the other hand, approaches this differently: interfaces are implicitly satisfied by defining all of the methods in its interface definition. This is a powerful and flexible feature, since it makes testing code using external libraries much easier – but it comes with the notable drawback that patterns like “marker interfaces” or “closed interfaces” are not immediately possible.

Thankfully, there actually is a way to accomplish this by leveraging function visibility in Go.

Solution

Unexported Methods

Any type in Go may implement an exported func from an interface, but it turns out only types in the same package as the interface may implement unexported funcs of an interface.

Let’s see what this means:

package example

type ClosedInterface interface {
  closed() // <-- Unexported func
}

type Widget struct{}

func (w Widget) closed() {}

var _ ClosedInterface = (*Widget)(nil) // <-- This is allowed

In this example, the ClosedInterface has a single unexported method closed, implemented by Widget. From the var _ assignment, we can see that Widget satisfies the ClosedInterface.

But what if we do this from outside the package?

package blogpost

import "rodusek.dev/post/2024-12-14/example"

type OtherWidget struct{}

func (w OtherWidget) closed() {}

var _ example.ClosedInterface = (*OtherWidget)(nil) // <-- This is not allowed!

In this one, we get an error that looks like:

Error: cannot use OtherWidget literal (type *OtherWidget) as type example.ClosedInterface in assignment

So we can see that the OtherWidget type is not allowed to implement the unexported closed method.

The take-away here is that you can control who can implement an interface by making the methods unexported. But what if you do want to allow other types to implement it, but limit the scope?

Embedding Types

Go allows for embedding types within struct definitions. The struct will then “inherit” all receivers from the embedded type into its interface, and more generally, will also now implement all interfaces that the embedded type implements.

This has a really interesting consequence: You can control which types implement an interface by forcing it to embed a specific type:

package example

type ClosedInterface interface {
  closed() // <-- Unexported func
}

type BaseClosedInterface struct{}

func (b BaseClosedInterface) closed() {}

var _ ClosedInterface = (*BaseClosedInterface)(nil)

In a new package, we can have:

package blogpost

import "rodusek.dev/post/2024-12-14/example"

type OtherWidget struct {
  example.BaseClosedInterface // <-- Embed the BaseClosedInterface
}

var _ example.ClosedInterface = (*OtherWidget)(nil) // <-- This is now allowed!

By using the embedded type, we can now control which types are allowed to implement the ClosedInterface by forcing them to embed the example.BaseClosedInterface.

Effective Use

Okay, so we have a way to control who can implement an interface, but lets see how we can use this effectively in practice.

Case 1: Future-Proofing APIs

Imagine you have a library that has an interface that is widely used, and users are able to implement this interface themselves and provide it to your library. You want to add a new method to this interface, but this would become a breaking change since all types that currently implement the interface will need to be updated to implement the new receiver func.

It turns out, having embeddable types here can help force your clients to be future-proof. You can force the user to embed a Base* type which implements an unexported func, while also providing “default” implementations to interface methods. If new methods are added, the Base* type updates with it, and all clients will inherit the new default for free, not forcing a code-change.

Example

Imagine you have an interface:

package example

import "errors"

type GreetingService interface {
  SayHello(name string) error
  service()
}

type BaseGreetingService struct{}

func (b BaseGreetingService) SayHello(name string) error {
  return errors.New("unimplemented")
}

func (b BaseGreetingService) service() {}

var _ GreetingService = (*BaseGreetingService)(nil)

Users can use this package like so:

package blogpost

import (
  "fmt"

  "rodusek.dev/post/2024-12-14/example"
)

type MyGreetingService struct {
  example.BaseGreetingService
}

func (m MyGreetingService) SayHello(name string) error {
  fmt.Println("Hello", name)
  return nil
}

var _ example.GreetingService = (*MyGreetingService)(nil)

You now get a new requirement that the GreetingService must also be able to say goodbye – thus you also want a SayGoodbye method. Supporting this now just becomes a matter of adding the method to both the interface and the BaseGreetingService:

package example

import "errors"

type GreetingService interface {
  SayHello(name string) error
  SayGoodbye(name string) error
  service()
}

type BaseGreetingService struct{}

func (b BaseGreetingService) SayHello(name string) error {
  return errors.New("unimplemented")
}

func (b BaseGreetingService) SayGoodbye(name string) error {
  return errors.New("unimplemented")
}

func (b BaseGreetingService) service() {}

var _ GreetingService = (*BaseGreetingService)(nil)

Any users updating their library will now automatically inherit the new SayGoodbye default implementation, and their code will continue to work. Since a user is forced to embed the BaseGreetingService, they are guaranteed to have the new method available.

Case 2: Marker Interfaces

Sometimes it can be desirable to design a library that either accepts or returns a fixed set of known types to the user, even if they may not have any explicit methods to implement. This is often referred to as a “Marker” interface.

By making the methods unexported, you can control which types are allowed to be passed to a function, and which are not. Callers or function implementations are then free to make expectations on the types returned by leveraging type-switches on these types and controlling the behavior.

Example

Imagine you are designing a library that defines a set of semantic types used as data-transfer-objects (DTOs). You want your API to only be able to speak in terms of these types, and you want to ensure that the user cannot pass in arbitrary types.

This can be easily accomplished by leveraging the unexported methods on interfaces.

package example

type Primitive interface {
  primitive()
}

type String string
func (String) primitive() {}

type Int int
func (Int) primitive() {}

// ...

var _ Primitive = (*String)(nil)
var _ Primitive = (*Int)(nil)

type Service struct { /* ... */ }

func (s *Service) Process(p Primitive) {
  switch p.(type) {
  case String:
    s.processString(p.(String))
  case Int:
    s.processInt(p.(Int))
  // ...
  }
}

In this example, the Primitive interface is a marker interface that is satisfied by String and Int. The Service struct can then process these types by leveraging type-switches on the Primitive interface.

Closing Thoughts

Go’s interfaces are powerful tools that allow for a lot of flexibility in designing APIs. By leveraging unexported methods, you can provide a tighter control over who can implement an interface, and how they can be used.

This can be useful for ensuring backwards compatibility, or for designing APIs that are more restrictive in the types they accept.