Go Type System — Interfaces, Embedding and Generics

Go takes a distinctive approach to polymorphism and code reuse. There are no classes, no inheritance hierarchies, and no explicit implements declarations. Instead, Go provides three orthogonal mechanisms: interfaces for abstraction, embedding for composition, and generics for type-safe parameterization. Each one is simple on its own, and together they cover the same ground that class hierarchies cover in other languages — with less coupling. In this post, we’ll work through all three, starting with how Go interfaces are satisfied implicitly, then moving to struct and interface embedding, and finishing with generic functions and types.

Interfaces and Type Assertions

Implicit Satisfaction

In Go, a type satisfies an interface simply by implementing all of its methods. There is no implements keyword and no explicit declaration binding a type to an interface. If the methods match, the type qualifies.

Consider a Message interface with two methods:

1
2
3
4
type Message interface {
    Type() string
    String() string
}

Any struct that has both a Type() method and a String() method satisfies Message automatically. Here are three structs that each represent a different kind of database operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Insert struct {
    Table string
    Data  map[string]string
}

func (i Insert) Type() string   { return "INSERT" }
func (i Insert) String() string { return fmt.Sprintf("INSERT into %s: %v", i.Table, i.Data) }

type Update struct {
    Table string
    Key   string
    Data  map[string]string
}

func (u Update) Type() string   { return "UPDATE" }
func (u Update) String() string { return fmt.Sprintf("UPDATE %s where key=%s: %v", u.Table, u.Key, u.Data) }

type Delete struct {
    Table string
    Key   string
}

func (d Delete) Type() string   { return "DELETE" }
func (d Delete) String() string { return fmt.Sprintf("DELETE from %s where key=%s", d.Table, d.Key) }

None of these types mention Message anywhere. Yet all three satisfy it, and you can store them together in a slice:

1
2
3
4
5
6
7
8
9
10
var msg Message = Insert{Table: "users", Data: map[string]string{"name": "alice"}}
fmt.Printf("msg.Type() = %s\n", msg.Type())
fmt.Printf("msg.String() = %s\n", msg)

messages := []Message{
    Insert{Table: "users", Data: map[string]string{"name": "alice"}},
    Update{Table: "users", Key: "alice", Data: map[string]string{"email": "a@b.com"}},
    Delete{Table: "sessions", Key: "expired-123"},
}
fmt.Printf("%d messages in slice, all satisfy Message interface\n", len(messages))

This implicit satisfaction is what makes Go interfaces so powerful for decoupling. A package can define an interface, and types from completely unrelated packages can satisfy it without importing or even knowing about the interface definition.

Type Switches

When you have an interface value and need to branch on the concrete type behind it, Go provides the type switch:

1
2
3
4
5
6
7
8
9
10
11
12
func dispatch(msg Message) {
    switch m := msg.(type) {
    case *Insert:
        fmt.Printf("[DISPATCH] Insert into %s: %v\n", m.Table, m.Data)
    case *Update:
        fmt.Printf("[DISPATCH] Update %s (key=%s): %v\n", m.Table, m.Key, m.Data)
    case *Delete:
        fmt.Printf("[DISPATCH] Delete from %s (key=%s)\n", m.Table, m.Key)
    default:
        fmt.Printf("[DISPATCH] Unknown message type: %s\n", m.Type())
    }
}

The syntax msg.(type) extracts the concrete type. In each case branch, the variable m is already narrowed to the matched type — you can access m.Table, m.Key, or m.Data directly without any additional casting. The default branch handles any Message implementation that was not explicitly listed.

1
2
3
4
5
6
7
8
messages := []Message{
    &Insert{Table: "users", Data: map[string]string{"name": "bob"}},
    &Delete{Table: "sessions", Key: "sess-456"},
    &Update{Table: "users", Key: "bob", Data: map[string]string{"role": "admin"}},
}
for _, msg := range messages {
    dispatch(msg)
}

Type Assertion with Comma-Ok

A type switch is useful when you need to handle multiple types. When you only care about one specific type, use a type assertion with the comma-ok pattern:

1
2
3
4
5
6
7
8
func tryExtractInsert(msg Message) {
    ins, ok := msg.(Insert)
    if ok {
        fmt.Printf("type assertion succeeded: Insert into %s\n", ins.Table)
    } else {
        fmt.Printf("type assertion failed: message is %s, not Insert\n", msg.Type())
    }
}

The expression msg.(Insert) attempts to extract the concrete Insert value from the interface. If the underlying type matches, ok is true and ins holds the value. If it does not match, ok is false and ins is the zero value of Insert. This two-value form is safe — it never panics. If you omit the second return value (ins := msg.(Insert)), a failed assertion will panic at runtime.

1
2
3
4
5
var msg Message = Insert{Table: "orders", Data: map[string]string{"id": "100"}}
tryExtractInsert(msg) // succeeds

var msg2 Message = Delete{Table: "orders", Key: "100"}
tryExtractInsert(msg2) // fails gracefully

Error as an Interface

Go’s built-in error type is itself an interface with a single method:

1
2
3
type error interface {
    Error() string
}

Any type that has an Error() string method satisfies the error interface. This means you can create custom error types that carry structured data beyond a simple message string:

1
2
3
4
5
6
7
8
type ParseError struct {
    Code    int
    Message string
}

func (e *ParseError) Error() string {
    return fmt.Sprintf("parse error %d: %s", e.Code, e.Message)
}

Because *ParseError has an Error() string method, it satisfies the error interface. You can return it wherever an error is expected, and callers can use a type assertion to extract the richer information:

1
2
3
4
5
6
var err error = &ParseError{Code: 422, Message: "invalid WAL format"}
fmt.Printf("err.Error() = %s\n", err.Error())

if pe, ok := err.(*ParseError); ok {
    fmt.Printf("extracted ParseError: code=%d, message=%s\n", pe.Code, pe.Message)
}

Note the assertion is against *ParseError (a pointer), because the Error() method has a pointer receiver. The receiver type matters — ParseError (value) would not match here.

The any Type

Go 1.18 introduced any as an alias for interface{}, the empty interface. Since every type satisfies an interface with zero methods, any can hold a value of any type. A type switch lets you recover the concrete type:

1
2
3
4
5
6
7
8
9
10
11
12
func processAny(val any) {
    switch v := val.(type) {
    case string:
        fmt.Printf("any is string: %q\n", v)
    case int:
        fmt.Printf("any is int: %d\n", v)
    case Message:
        fmt.Printf("any is Message: %s\n", v)
    default:
        fmt.Printf("any is unknown: %T\n", v)
    }
}
1
2
3
4
processAny("hello")                              // string
processAny(42)                                    // int
processAny(Insert{Table: "t", Data: nil})         // Message
processAny(3.14)                                  // unknown: float64

The any type is useful for truly generic containers and APIs. However, it erases type information at compile time. For most cases where you want type-safe parameterization, generics (covered below) are the better tool.

Embedding

Struct Embedding

Go does not have inheritance. Instead, it has embedding — you place one struct type inside another without giving it a field name, and its fields and methods are “promoted” to the outer struct.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Base struct {
    ID        int
    CreatedAt string
}

func (b Base) Summary() string {
    return fmt.Sprintf("id=%d, created=%s", b.ID, b.CreatedAt)
}

type User struct {
    Base
    Name  string
    Email string
}

The User struct embeds Base. There is no field name — just the type. This is different from a named field like base Base; embedding promotes all of Base’s fields and methods onto User.

Field and Method Promotion

Because Base is embedded, you can access its fields directly on a User value:

1
2
3
4
5
6
7
8
u := User{
    Base:  Base{ID: 1, CreatedAt: "2025-01-15"},
    Name:  "Alice",
    Email: "alice@example.com",
}

fmt.Printf("user.ID: %d\n", u.ID)           // promoted from Base
fmt.Printf("user.Base.ID: %d\n", u.Base.ID) // explicit path also works

Both u.ID and u.Base.ID refer to the same field. The promoted path is syntactic convenience — the compiler rewrites u.ID to u.Base.ID behind the scenes.

Methods are promoted the same way. Since Base has a Summary() method, User also has Summary() without writing any additional code:

1
fmt.Printf("u.Summary() = %s\n", u.Summary()) // calls Base.Summary()

This also means User satisfies any interface that Base satisfies. If there is a Summarizer interface requiring a Summary() string method, User satisfies it through promotion:

1
2
3
4
5
6
7
8
9
type Summarizer interface {
    Summary() string
}

func printSummary(s Summarizer) {
    fmt.Printf("summary: %s\n", s.Summary())
}

printSummary(u) // works — User has Summary() via Base

Method Override

An outer type can define its own version of a promoted method, effectively overriding it:

1
2
3
4
5
6
7
8
9
type Order struct {
    Base
    UserID int
    Total  float64
}

func (o Order) Summary() string {
    return fmt.Sprintf("order %d: $%.2f (user %d)", o.ID, o.Total, o.UserID)
}

Now Order has its own Summary() that takes priority over the promoted one from Base. The original is still accessible through the explicit path:

1
2
3
4
o := Order{Base: Base{ID: 42, CreatedAt: "2025-03-01"}, UserID: 1, Total: 99.95}

fmt.Printf("o.Summary() = %s\n", o.Summary())      // Order.Summary()
fmt.Printf("o.Base.Summary() = %s\n", o.Base.Summary()) // Base.Summary()

Interface Embedding

Embedding also works with interfaces. You can compose larger interfaces from smaller ones:

1
2
3
4
5
6
7
8
9
10
11
12
type Reader interface {
    Read() string
}

type Writer interface {
    Write(data string)
}

type ReadWriter interface {
    Reader
    Writer
}

ReadWriter embeds both Reader and Writer, so any type that satisfies ReadWriter must implement both Read() and Write(). A concrete type can satisfy all three interfaces at once:

1
2
3
4
5
6
7
type File struct {
    Name    string
    content string
}

func (f File) Read() string       { return f.content }
func (f *File) Write(data string) { f.content += data }
1
2
3
4
5
6
7
8
9
10
f := &File{Name: "data.txt", content: "hello"}

var r Reader = f
fmt.Printf("Reader: %s\n", r.Read())

var w Writer = f
w.Write(" world")

var rw ReadWriter = f
fmt.Printf("ReadWriter: %s\n", rw.Read())

This is the same pattern the standard library uses. io.ReadWriter embeds io.Reader and io.Writer, and types like os.File and bytes.Buffer satisfy all three.

Embedding vs Inheritance

Embedding looks superficially like inheritance, but it has a fundamental difference: the embedded type has no knowledge of the outer type. In classical inheritance, a base class method can call an overridden method on the subclass (dynamic dispatch). With Go embedding, Base.Summary() will always run Base’s logic — it cannot “see” that it is embedded inside User or Order.

1
2
3
u := User{Base: Base{ID: 1, CreatedAt: "2025-01-15"}, Name: "Alice"}
var s Summarizer = u
fmt.Printf("Summarizer: %s\n", s.Summary())

The key differences:

  • Base has no idea User exists.
  • There is no polymorphism on the embedded type — Base methods do not dispatch to User methods.
  • User “has a” Base, not “is a” Base.
  • It is composition with promoted access, not inheritance.

Generics

Go 1.18 introduced generics (type parameters), letting you write functions and types that work across multiple types without sacrificing compile-time type safety.

Generic Functions

The most common use of generics is writing utility functions that operate on slices of any type. Here is a generic Filter function:

1
2
3
4
5
6
7
8
9
func Filter[T any](slice []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

The [T any] after the function name declares a type parameter T with the constraint any — meaning T can be any type. The compiler generates specialized code for each concrete type used at call sites, so there is no runtime overhead.

A Map function follows the same pattern but transforms each element:

1
2
3
4
5
6
7
func Map[T any, U any](slice []T, transform func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = transform(v)
    }
    return result
}

Using them:

1
2
3
4
5
6
7
8
9
10
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
// evens = [2, 4, 6, 8, 10]

words := []string{"hello", "world", "go"}
upper := Map(words, strings.ToUpper)
// upper = ["HELLO", "WORLD", "GO"]

lengths := Map(words, func(s string) int { return len(s) })
// lengths = [5, 5, 2]

Notice that you do not need to specify the type parameters explicitly at the call site — Go infers them from the arguments.

Type Constraints

The any constraint is maximally permissive, but it only lets you do things that work on every type (pass values around, store them in slices). If you need to perform operations like addition, you need a narrower constraint.

A type constraint is an interface that lists the allowed types using the union syntax:

1
2
3
4
5
6
7
8
9
10
11
type Number interface {
    int | float64
}

func Sum[T Number](values []T) T {
    var total T
    for _, v := range values {
        total += v
    }
    return total
}

The Number constraint restricts T to int or float64. The += operator is valid because both types support it. The compiler rejects any call that passes a type not in the union:

1
2
3
fmt.Println(Sum([]int{1, 2, 3, 4, 5}))          // 15
fmt.Println(Sum([]float64{1.1, 2.2, 3.3}))       // 6.6
// Sum([]string{"a", "b"})                        // compile error

The standard library provides common constraints in the golang.org/x/exp/constraints package (e.g., constraints.Ordered for any type that supports <, >, <=, >=).

Generic Structs

Type parameters work on structs too. Here is a generic stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

func (s *Stack[T]) Peek() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) IsEmpty() bool {
    return len(s.items) == 0
}

You specify the type parameter when creating an instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
intStack := &Stack[int]{}
intStack.Push(10)
intStack.Push(20)
intStack.Push(30)

v, ok := intStack.Pop()
// v = 30, ok = true

strStack := &Stack[string]{}
strStack.Push("a")
strStack.Push("b")
top, _ := strStack.Peek()
// top = "b"

The var zero T pattern is worth noting — it is the idiomatic way to get the zero value of a generic type. For int it is 0, for string it is "", for pointers it is nil.

Multiple Type Parameters

A generic type can have more than one type parameter. Here is a Pair that holds two values of different types:

1
2
3
4
5
6
7
8
type Pair[K any, V any] struct {
    Key   K
    Value V
}

func (p Pair[K, V]) String() string {
    return fmt.Sprintf("(%v, %v)", p.Key, p.Value)
}

A Zip function combines two slices into a slice of pairs:

1
2
3
4
5
6
7
8
9
10
11
func Zip[K any, V any](keys []K, values []V) []Pair[K, V] {
    n := len(keys)
    if len(values) < n {
        n = len(values)
    }
    pairs := make([]Pair[K, V], n)
    for i := 0; i < n; i++ {
        pairs[i] = Pair[K, V]{Key: keys[i], Value: values[i]}
    }
    return pairs
}

And GroupBy uses a comparable constraint on the key type, since map keys must be comparable:

1
2
3
4
5
6
7
8
func GroupBy[T any, K comparable](items []T, keyFn func(T) K) map[K][]T {
    groups := make(map[K][]T)
    for _, item := range items {
        k := keyFn(item)
        groups[k] = append(groups[k], item)
    }
    return groups
}
1
2
3
4
5
6
pairs := Zip([]string{"a", "b", "c"}, []int{1, 2, 3})
// [(a, 1), (b, 2), (c, 3)]

words := []string{"apple", "avocado", "banana", "blueberry", "cherry"}
grouped := GroupBy(words, func(s string) string { return string(s[0]) })
// {"a": ["apple", "avocado"], "b": ["banana", "blueberry"], "c": ["cherry"]}

The comparable constraint is a built-in that permits any type supporting == and !=. It is required for map keys and is narrower than any but broader than specific type unions.

Interface Constraints

You can use any interface as a type constraint, not just unions of primitive types. This lets you write generic functions that call methods on their type parameters:

1
2
3
4
5
6
7
8
9
10
11
type Stringer interface {
    String() string
}

func JoinStrings[T Stringer](items []T, sep string) string {
    parts := make([]string, len(items))
    for i, item := range items {
        parts[i] = item.String()
    }
    return strings.Join(parts, sep)
}

Any type that has a String() string method can be used with JoinStrings:

1
2
3
4
5
6
7
8
9
10
11
type Color struct {
    Name string
    Hex  string
}
func (c Color) String() string { return fmt.Sprintf("%s(%s)", c.Name, c.Hex) }

type City struct {
    Name    string
    Country string
}
func (c City) String() string { return fmt.Sprintf("%s, %s", c.Name, c.Country) }
1
2
3
4
5
6
7
8
9
10
11
12
13
colors := []Color{
    {Name: "Red", Hex: "#FF0000"},
    {Name: "Green", Hex: "#00FF00"},
}
fmt.Println(JoinStrings(colors, ", "))
// Red(#FF0000), Green(#00FF00)

cities := []City{
    {Name: "Tokyo", Country: "Japan"},
    {Name: "Paris", Country: "France"},
}
fmt.Println(JoinStrings(cities, " | "))
// Tokyo, Japan | Paris, France

The difference between an interface constraint and any is enforcement at compile time. With any, you cannot call .String() on the type parameter because the compiler does not know it exists. With Stringer, the compiler guarantees every type passed to JoinStrings has that method, so the call is safe. This is the same implicit satisfaction mechanism from the interfaces section — the constraint is an interface, and the concrete type must satisfy it.