Structs en interfaces in Go: uitgelegd voor beginners

Leer hoe structs en interfaces werken in Go. Met praktische voorbeelden, methods, embedding en best practices voor schone, uitbreidbare code.

4 augustus 20266 min leestijdDoor We Develop Communication

Als je serieus aan de slag wilt met Go, ontkom je niet aan structs en interfaces. Ze vormen samen het fundament van bijna elke Go-applicatie: structs modelleren je data, interfaces beschrijven gedrag. Begrijp je dit duo eenmaal, dan wordt Go-code plots veel logischer, en schrijf je zelf code die flexibel, testbaar en schoon is.

In deze gids neem ik je stap voor stap mee. We beginnen bij de basis, bouwen naar methods, embedding en polymorfisme, en sluiten af met best practices uit de praktijk. Heb je Go nog niet lokaal draaien? Lees dan eerst onze installatiegids.

Wat is een struct in Go?

Een struct is een benoemde verzameling velden. Je gebruikt het om een logisch geheel van data bij elkaar te zetten, denk aan een gebruiker, een product of een HTTP-request.

type User struct {
    ID       int
    Name     string
    Email    string
    Active   bool
}

Je maakt een instantie als volgt aan:

u := User{
    ID:     1,
    Name:   "Sander",
    Email:  "[email protected]",
    Active: true,
}

fmt.Println(u.Name) // Sander

Een struct lijkt op een object uit andere talen, maar is puur data. Het heeft geen constructor en geen ingebouwde overerving. Simpel, expliciet en voorspelbaar, precies de Go-filosofie.

Zero values en initialisatie

Declareer je een struct zonder waarden, dan krijgt elk veld zijn zero value: 0 voor ints, "" voor strings, false voor bools, en nil voor pointers en slices.

var u User
fmt.Println(u) // {0  false}

Dit is handig: je hoeft zelden te checken of iets "geïnitialiseerd" is. Meer over types en hun zero values lees je in variabelen en types in Go.

Methods op structs

Een struct wordt pas echt krachtig als je er methods aan koppelt. In Go voeg je methods toe met een zogeheten receiver tussen func en de methodnaam.

func (u User) FullInfo() string {
    return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}

Vervolgens roep je dit aan op een instantie:

u := User{Name: "Sander", Email: "[email protected]"}
fmt.Println(u.FullInfo()) // Sander <[email protected]>

Value receivers vs pointer receivers

Een belangrijk onderscheid: wil je de struct wijzigen binnen de method, gebruik dan een pointer receiver.

func (u *User) Deactivate() {
    u.Active = false
}

Richtlijnen:

  • Value receiver (u User): voor kleine, onveranderlijke structs zonder state-wijziging.
  • Pointer receiver (u *User): als je velden wilt aanpassen, of als de struct groot is en kopiëren duur wordt.

Een goede vuistregel: wees consistent binnen één type. Mix pointer en value receivers niet willekeurig op dezelfde struct.

Wat is een interface in Go?

Een interface is een verzameling method-signatures. Het zegt niet hoe iets moet werken, maar welk gedrag een type moet vertonen.

type Notifier interface {
    Notify(message string) error
}

Elk type dat een method Notify(message string) error heeft, voldoet automatisch aan de Notifier interface. Er is geen implements-keyword zoals in Java of C#. Dit heet impliciete interface satisfaction en is een van de elegantste eigenschappen van Go.

Een voorbeeld: meerdere notifiers

Stel, je wilt notificaties kunnen versturen via e-mail én Slack. Met interfaces is dat triviaal:

type EmailNotifier struct {
    From string
}

func (e EmailNotifier) Notify(message string) error {
    fmt.Printf("Email van %s: %s\n", e.From, message)
    return nil
}

type SlackNotifier struct {
    Webhook string
}

func (s SlackNotifier) Notify(message string) error {
    fmt.Printf("Slack naar %s: %s\n", s.Webhook, message)
    return nil
}

Beide types voldoen aan Notifier zonder dat je dat ergens hoeft te declareren. In je business-logica werk je vervolgens abstract:

func alert(n Notifier, msg string) {
    if err := n.Notify(msg); err != nil {
        log.Println("notificatie faalde:", err)
    }
}

alert(EmailNotifier{From: "[email protected]"}, "Server is down!")
alert(SlackNotifier{Webhook: "https://hooks.slack.com/..."}, "Deploy geslaagd")

Deze losse koppeling maakt je code makkelijk testbaar en uitbreidbaar, een belangrijke reden waarom Go zo populair is voor backends.

De lege interface: interface{} en any

Soms weet je het type niet vooraf. Daar bestaat de lege interface voor, sinds Go 1.18 beschikbaar als het alias any:

func printAnything(v any) {
    fmt.Println(v)
}

Elk type voldoet aan any, want elk type heeft nul of meer methodes. Gebruik any spaarzaam: je verliest type-veiligheid en hebt vaak type assertions of type switches nodig om er iets nuttigs mee te doen.

func describe(v any) {
    switch x := v.(type) {
    case int:
        fmt.Println("int:", x)
    case string:
        fmt.Println("string:", x)
    default:
        fmt.Println("onbekend type")
    }
}

Struct embedding: compositie boven overerving

Go kent geen overerving, maar wél embedding. Door een struct zonder veldnaam op te nemen, krijg je direct toegang tot zijn velden en methodes.

type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Post struct {
    Timestamps
    Title string
    Body  string
}

p := Post{
    Timestamps: Timestamps{CreatedAt: time.Now()},
    Title:      "Structs en interfaces",
}

fmt.Println(p.CreatedAt) // werkt direct

Dit patroon is krachtig: je hergebruikt gedrag zonder een ingewikkelde class-hiërarchie. Voor gedeelde logica zoals logging of auditing is embedding de idiomatische keuze.

Interfaces en polymorfisme in de praktijk

Een klassiek voorbeeld uit de standaardbibliotheek is io.Writer:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Bestanden, netwerkverbindingen, buffers en zelfs os.Stdout voldoen aan deze interface. Daardoor werkt fmt.Fprintln met álles waar je tekst naartoe kunt schrijven, zonder dat het pakket iets weet over het onderliggende type.

Dit is polymorfisme in Go-stijl: klein, onzichtbaar en zonder ceremonie.

Accepteer interfaces, retourneer structs

Een bekende Go-wijsheid: accepteer interfaces, retourneer concrete types. Zo houd je je API's flexibel voor callers, maar geef je zelf volledige informatie terug.

// goed
func NewService(n Notifier) *Service { ... }

// minder flexibel
func NewService(n EmailNotifier) *Service { ... }

Best practices voor structs en interfaces

Een paar richtlijnen die je later veel tijd besparen:

  1. Houd interfaces klein. De beste Go-interfaces hebben één of twee methodes. io.Reader, io.Writer, fmt.Stringer, ze zijn allemaal minimaal.
  2. Definieer interfaces bij de consument, niet bij de producent. Het pakket dat de interface gebruikt is degene dat weet welk gedrag het nodig heeft.
  3. Exporteer structs met PascalCase, private velden met camelCase. Zo controleer je zichtbaarheid zonder extra keywords.
  4. Vermijd onnodige getters en setters. Geef velden direct vrij als dat veilig kan, Go is geen Java.
  5. Gebruik constructor-functies zoals NewUser(...) voor validatie of complexe initialisatie.
  6. Embed bewust. Embedding is krachtig, maar maakt APIs wel breder. Weet wat je exposeert.

Veel van deze principes komen terug in Effective Go, de officiële stijlgids van het Go-team. Verplichte kost als je verder wilt groeien.

Een realistisch voorbeeld: een user repository

Laten we alles samenbrengen. Stel, je bouwt een simpele repository voor gebruikers:

type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(u *User) error
}

type InMemoryUserRepo struct {
    users map[int]*User
}

func NewInMemoryUserRepo() *InMemoryUserRepo {
    return &InMemoryUserRepo{users: make(map[int]*User)}
}

func (r *InMemoryUserRepo) FindByID(id int) (*User, error) {
    u, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("user %d niet gevonden", id)
    }
    return u, nil
}

func (r *InMemoryUserRepo) Save(u *User) error {
    r.users[u.ID] = u
    return nil
}

In tests wissel je deze implementatie zó in voor een mock die óók aan UserRepository voldoet. Geen frameworks nodig, geen ingewikkelde mocking-libraries, gewoon interfaces die hun werk doen.

Veelgestelde vragen

Wat is het verschil tussen een struct en een interface in Go?

Een struct is een concrete datastructuur die velden bevat, terwijl een interface een set methodes beschrijft waaraan types moeten voldoen. Structs bewaren data, interfaces definiëren gedrag.

Hoe implementeer je een interface in Go?

In Go implementeer je een interface impliciet. Zodra een type alle methodes van een interface heeft, voldoet het automatisch aan die interface. Je hoeft dit nergens expliciet aan te geven.

Wanneer gebruik je een pointer receiver bij een struct method?

Gebruik een pointer receiver als je de struct wilt wijzigen binnen de method, of als de struct groot is en je kopieën wilt vermijden. Voor kleine, onveranderlijke structs volstaat een value receiver.

Wat is embedding in Go?

Embedding is het opnemen van een struct binnen een andere struct zonder expliciete veldnaam. De velden en methodes van de embedded struct worden dan direct beschikbaar op de outer struct, wat compositie boven overerving mogelijk maakt.

Waarom heeft Go geen klassen zoals andere talen?

Go kiest bewust voor eenvoud en compositie in plaats van klassieke overerving. Met structs, methodes en interfaces bereik je dezelfde flexibiliteit, zonder de complexiteit van class hierarchies.

Conclusie

Structs geven je data een vorm, interfaces geven je code flexibiliteit. Samen vormen ze het hart van idiomatische Go: expliciet waar het moet, abstract waar het kan, en altijd leesbaar.

Begin klein. Modelleer één struct, voeg een method toe, definieer een interface met één methode. Voor je het weet schrijf je Go-code die niet alleen werkt, maar ook aanvoelt alsof ze door de taal zélf bedoeld is.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.