In the previous tutorial, you learned about structs, methods, and composition. Now it is time to learn about interfaces — one of Go’s most powerful features.

Interfaces in Go are different from most languages. There is no implements keyword. If a type has the right methods, it automatically satisfies the interface. This is called implicit implementation.

What is an Interface?

An interface defines a set of method signatures. Any type that implements all those methods satisfies the interface:

package main

import (
    "fmt"
    "math"
)

// Interface — defines behavior
type Shape interface {
    Area() float64
    Perimeter() float64
}

// Circle implements Shape (implicitly)
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

// Rectangle implements Shape (implicitly)
type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// This function accepts any Shape
func printShape(s Shape) {
    fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
    circle := Circle{Radius: 5}
    rect := Rectangle{Width: 10, Height: 3}

    fmt.Print("Circle — ")
    printShape(circle)

    fmt.Print("Rectangle — ")
    printShape(rect)
}

Output:

Circle — Area: 78.54, Perimeter: 31.42
Rectangle — Area: 30.00, Perimeter: 26.00

Notice: Circle and Rectangle never say they implement Shape. They just have the Area() and Perimeter() methods. Go checks this at compile time.

Why Implicit Implementation Matters

In Java or C#, you must write implements Shape. In Go, you don’t. This means:

  1. You can define interfaces after the types exist. You can create an interface for types from other packages, even standard library types.
  2. Small interfaces are easy to create. You don’t need to go back and add implements to existing code.
  3. Less coupling. The type doesn’t need to know about the interface.

This design encourages small, focused interfaces.

The Empty Interface and any

The empty interface has no methods. Every type satisfies it:

package main

import "fmt"

func printAnything(value interface{}) {
    fmt.Printf("Type: %T, Value: %v\n", value, value)
}

func main() {
    printAnything(42)
    printAnything("hello")
    printAnything(3.14)
    printAnything(true)
    printAnything([]int{1, 2, 3})
}

Output:

Type: int, Value: 42
Type: string, Value: hello
Type: float64, Value: 3.14
Type: bool, Value: true
Type: []int, Value: [1 2 3]

Since Go 1.18, you can use any instead of interface{}. They are the same:

func printAnything(value any) {
    fmt.Printf("Type: %T, Value: %v\n", value, value)
}

Use any sparingly. It turns off type checking. Prefer specific interfaces when possible.

Type Assertions

A type assertion extracts the concrete type from an interface:

package main

import "fmt"

func main() {
    var value interface{} = "hello"

    // Type assertion — extract the string
    str := value.(string)
    fmt.Println("String:", str)

    // This would panic if the type is wrong:
    // num := value.(int) // panic: interface conversion

    // Safe type assertion with ok check
    num, ok := value.(int)
    if ok {
        fmt.Println("Integer:", num)
    } else {
        fmt.Println("Not an integer")
    }

    // Common pattern
    if str, ok := value.(string); ok {
        fmt.Println("It is a string:", str)
    }
}

Output:

String: hello
Not an integer
It is a string: hello

Always use the two-value form (value, ok) to avoid panics. The single-value form panics if the type does not match.

Type Switches

A type switch checks the type of an interface value. It is cleaner than multiple type assertions:

package main

import "fmt"

func describe(value interface{}) string {
    switch v := value.(type) {
    case int:
        return fmt.Sprintf("Integer %d (doubled: %d)", v, v*2)
    case float64:
        return fmt.Sprintf("Float %.2f", v)
    case string:
        return fmt.Sprintf("String %q (length: %d)", v, len(v))
    case bool:
        if v {
            return "Boolean: true"
        }
        return "Boolean: false"
    case nil:
        return "nil"
    default:
        return fmt.Sprintf("Unknown type: %T", v)
    }
}

func main() {
    values := []interface{}{42, 3.14, "hello", true, nil, []int{1, 2}}
    for _, v := range values {
        fmt.Println(describe(v))
    }
}

Output:

Integer 42 (doubled: 84)
Float 3.14
String "hello" (length: 5)
Boolean: true
nil
Unknown type: []int

The variable v gets the correct type inside each case branch. This makes it safe to use type-specific operations.

Interface Composition

You can combine small interfaces into larger ones:

package main

import "fmt"

type Reader interface {
    Read(data []byte) (int, error)
}

type Writer interface {
    Write(data []byte) (int, error)
}

// ReadWriter combines Reader and Writer
type ReadWriter interface {
    Reader
    Writer
}

// Closer is another small interface
type Closer interface {
    Close() error
}

// ReadWriteCloser combines all three
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

// A simple implementation
type Buffer struct {
    data []byte
}

func (b *Buffer) Read(p []byte) (int, error) {
    n := copy(p, b.data)
    return n, nil
}

func (b *Buffer) Write(p []byte) (int, error) {
    b.data = append(b.data, p...)
    return len(p), nil
}

func (b *Buffer) Close() error {
    b.data = nil
    return nil
}

func main() {
    var rw ReadWriter = &Buffer{}
    rw.Write([]byte("hello"))

    buf := make([]byte, 10)
    n, _ := rw.Read(buf)
    fmt.Println("Read:", string(buf[:n]))
}

Output:

Read: hello

This is how the Go standard library works. The io package defines small interfaces (Reader, Writer, Closer) and combines them into larger ones (ReadWriter, ReadWriteCloser).

Common Standard Library Interfaces

Go’s standard library has several important interfaces you should know:

fmt.Stringer

Implement String() to control how your type is printed:

package main

import "fmt"

type Color struct {
    R, G, B uint8
}

func (c Color) String() string {
    return fmt.Sprintf("rgb(%d, %d, %d)", c.R, c.G, c.B)
}

func main() {
    red := Color{R: 255, G: 0, B: 0}
    blue := Color{R: 0, G: 0, B: 255}

    fmt.Println(red)  // rgb(255, 0, 0)
    fmt.Println(blue) // rgb(0, 0, 255)
}

error Interface

The error interface is one of Go’s most used interfaces:

package main

import "fmt"

// The error interface is: type error interface { Error() string }

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: %s — %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be positive"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "must be under 150"}
    }
    return nil
}

func main() {
    if err := validateAge(-5); err != nil {
        fmt.Println(err)
    }

    if err := validateAge(25); err != nil {
        fmt.Println(err)
    } else {
        fmt.Println("Age 25 is valid")
    }
}

Output:

validation error: age — must be positive
Age 25 is valid

sort.Interface

Implement three methods to make any collection sortable:

package main

import (
    "fmt"
    "sort"
)

type Employee struct {
    Name   string
    Salary float64
}

// ByName implements sort.Interface
type ByName []Employee

func (b ByName) Len() int           { return len(b) }
func (b ByName) Less(i, j int) bool { return b[i].Name < b[j].Name }
func (b ByName) Swap(i, j int)      { b[i], b[j] = b[j], b[i] }

func main() {
    employees := []Employee{
        {Name: "Sam", Salary: 60000},
        {Name: "Alex", Salary: 75000},
        {Name: "Jordan", Salary: 55000},
    }

    sort.Sort(ByName(employees))

    for _, e := range employees {
        fmt.Printf("%-8s $%.0f\n", e.Name, e.Salary)
    }
}

Output:

Alex     $75000
Jordan   $55000
Sam      $60000

In modern Go, you can also use sort.Slice which is simpler for one-off sorting:

sort.Slice(employees, func(i, j int) bool {
    return employees[i].Salary > employees[j].Salary
})

Accept Interfaces, Return Structs

This is an important Go design principle. Functions should accept interfaces as parameters and return concrete types:

package main

import (
    "fmt"
    "io"
    "strings"
)

// Accept an interface — any io.Reader works
func countBytes(r io.Reader) (int, error) {
    buf := make([]byte, 1024)
    total := 0
    for {
        n, err := r.Read(buf)
        total += n
        if err == io.EOF {
            break
        }
        if err != nil {
            return total, err
        }
    }
    return total, nil
}

func main() {
    // strings.NewReader returns a concrete type, but we pass it as io.Reader
    reader := strings.NewReader("Hello, Go interfaces!")
    count, err := countBytes(reader)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Printf("Counted %d bytes\n", count)
}

Output:

Counted 21 bytes

Why this pattern?

  • Accept interfaces: Your function works with any type that satisfies the interface. More flexible, easier to test.
  • Return structs: The caller gets the full concrete type with all its methods. No information is lost.

A Complete Example

Here is a program that shows interfaces in action with a simple notification system:

package main

import "fmt"

// Small, focused interface
type Notifier interface {
    Notify(message string) error
}

// Email notification
type EmailNotifier struct {
    Address string
}

func (e *EmailNotifier) Notify(message string) error {
    fmt.Printf("[Email to %s]: %s\n", e.Address, message)
    return nil
}

// SMS notification
type SMSNotifier struct {
    Phone string
}

func (s *SMSNotifier) Notify(message string) error {
    fmt.Printf("[SMS to %s]: %s\n", s.Phone, message)
    return nil
}

// Slack notification
type SlackNotifier struct {
    Channel string
}

func (sl *SlackNotifier) Notify(message string) error {
    fmt.Printf("[Slack #%s]: %s\n", sl.Channel, message)
    return nil
}

// Send to multiple notifiers — accepts the interface
func broadcast(notifiers []Notifier, message string) {
    for _, n := range notifiers {
        if err := n.Notify(message); err != nil {
            fmt.Printf("Error: %v\n", err)
        }
    }
}

func main() {
    fmt.Println("=== GO-8: Interfaces and Polymorphism ===")
    fmt.Println()

    notifiers := []Notifier{
        &EmailNotifier{Address: "alex@example.com"},
        &SMSNotifier{Phone: "+49-123-456"},
        &SlackNotifier{Channel: "alerts"},
    }

    broadcast(notifiers, "Server is back online")
    fmt.Println()
    broadcast(notifiers, "Deploy completed successfully")
}

Output:

=== GO-8: Interfaces and Polymorphism ===

[Email to alex@example.com]: Server is back online
[SMS to +49-123-456]: Server is back online
[Slack #alerts]: Server is back online

[Email to alex@example.com]: Deploy completed successfully
[SMS to +49-123-456]: Deploy completed successfully
[Slack #alerts]: Deploy completed successfully

Adding a new notification type (like a webhook) is easy. Just create a struct with a Notify method. No existing code needs to change.

Common Mistakes

1. Using too large interfaces.

// Too big — hard to implement and test
type Service interface {
    GetUser(id int) (*User, error)
    CreateUser(u *User) error
    DeleteUser(id int) error
    ListUsers() ([]*User, error)
    GetOrder(id int) (*Order, error)
    // ... 20 more methods
}

// Better — small, focused interfaces
type UserGetter interface {
    GetUser(id int) (*User, error)
}

type UserCreator interface {
    CreateUser(u *User) error
}

Go proverb: “The bigger the interface, the weaker the abstraction.” Keep interfaces small (1-3 methods).

2. Returning an interface instead of a concrete type.

// Avoid this — returns interface
func NewLogger() Logger {
    return &FileLogger{...}
}

// Better — returns concrete type
func NewFileLogger() *FileLogger {
    return &FileLogger{...}
}

Return concrete types so callers get full access to the type. Accept interfaces in function parameters for flexibility.

3. Forgetting the ok check in type assertions.

var v interface{} = "hello"

// Dangerous — panics if v is not an int
num := v.(int) // panic!

// Safe — check with ok
num, ok := v.(int)
if !ok {
    fmt.Println("Not an integer")
}

Always use the two-value form unless you are certain about the type.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-8 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #9: Pointers, you will learn:

  • What pointers are and how they work in Go
  • The & and * operators
  • When to use pointers vs values
  • Nil pointers and how to handle them
  • How Go pointers compare to Rust’s borrowing system

This is part 8 of the Go Tutorial series. Follow along to learn Go from scratch.