Golang Generics v.s. Interfaces: Best Practices

Sentio
3 min readFeb 9, 2023

Author: Kan Qiao

Introduction

Since Go 1.18, Go starts to support generics. However, this article is not what generics are (there are plenty of articles already), but when to use them? The obvious TLDR is: When you need to write the same logic for multiple types, use generics. This sounds easy to understand, but when we are facing the real cases, we have a another option: using interfaces. Then the question is: when to use generics over interfaces? We will discuss this using examples in this article.

Let’s Start With A Simple Example

sort.Sort provides a way to sort a slice of Interface.

func Sort(data Interface)

And the Interface is defined as:

type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}

Thus, if an array has a Len, Less and Swap method, it can be sorted by sort.Sort.

In a lot of cases, generics can achieve the same goal. And in most of the cases, they have similar performance. However, generics has a real advantage: You know the concrete type directly. Why is this so useful?

Why Do We Need To Know The Concrete Types?

Let’s take a look at another example. Assume you need to write a storage controller that can read/write data from/to a given file. You only need the object to be able to Marshal and Unmarshal to/from JSON. You can write the code like this:

type Object interface {
...
}
type FileStorage struct {
fileName string
}
func (s *FileStorage) Load(ctx context.Context, ch chan<- Object) error {
...
}
func (s *FileStorage) Save(ctx context.Context, ch <-chan Object) error {
...
}

For Save, there is no problem. Since the Object is an interface, you can pass any object that implements the Object interface. The actual type is known at runtime of Marshal. However, for Load, the problem is that you don’t know the actual type of the object before you Unmarshall it. A verbose way to solve this is to use a auxiliary struct to wrap the object with type information when you Marshall and Unmarshall it. That is very annoying.

But let’s think about it, why do we need store this information? The answer is: We don’t know the concrete type of the object because it is an interface. Using generics, you can solve this problem in a much more elegant way:

type FileStorage[OBJ any] struct {
fileName string
}
func (s *FileStorage[OBJ]) Load(ctx context.Context, ch chan<- OBJ) error {
orig, err := os.ReadFile(s.fileName)
if err != nil {
return err
}
var data []OBJ
if err = json.Unmarshal(orig, &data); err != nil {
return err
}
for _, obj := range data {
select {
case <-ctx.Done():
return ctx.Err()
case ch <- obj:
}
}
return nil
}

because the OBJ is a concrete type, you can use it directly in the Load function. You don’t need to use a wrapper struct to store the type information. The only issue you might have is that you need to instantiate the FileStorage with a concrete type. But this should be feasible in most cases as you only have a few types in your program.

Conclusion

In this article, we discussed when to use generics over interfaces. A more concrete best practices is:

  • For your controller objects, use interfaces to make them more flexible.
  • For your data objects, use generics to make your code more concise and elegant.

A Little About Sentio

Sentio is an observability platform for Web3. Sentio generates metrics, logs, and traces from existing smart contracts data through our low code solution, which could be used to build dashboards, set up alerts, analyze user behaviors, create API/Webhooks, simulate/debug transactions and more. Sentio supports Ethereum, BSC, Polygon, Solana, and Aptos. Sentio is built by veteran engineers from Google, Linkedin, Microsoft and TikTok, and backed by top investors like Lightspeed Venture Partners, Hashkey Capital and Canonical Crypto.

Visit Sentio at sentio.xyz. Follow us on Twitter for more updates.

--

--

Sentio

End-to-end observability platform to help you gain insights, secure assets and troubleshoot transactions for your decentralized applications.