Functions en error handling in Go uitgelegd

Leer hoe functions en error handling werken in Go. Van parameters en return values tot idiomatische foutafhandeling met praktische voorbeelden.

5 augustus 20267 min leestijdDoor We Develop Communication

Als je vanuit talen als PHP, JavaScript of Python naar Go komt, valt één ding meteen op: de manier waarop je met functions en error handling omgaat is fundamenteel anders. Geen try/catch, geen exceptions, geen verborgen controle-flows. In plaats daarvan krijg je expliciete errors als return value, en dat blijkt in de praktijk een verademing.

In dit artikel leer je hoe je functies schrijft in Go, hoe multiple return values werken, en waarom Go's idiomatische error handling je code robuuster maakt. We bouwen voort op eerdere artikelen uit onze serie, zoals variabelen en types in Go en structs en interfaces.

De anatomie van een Go functie

Een functie in Go begint altijd met het sleutelwoord func, gevolgd door de naam, parameters en eventueel return types. De syntaxis is compact maar expliciet.

func optellen(a int, b int) int {
    return a + b
}

Wat meteen opvalt: het type staat achter de parameternaam, niet ervoor zoals in C of Java. Dit past bij Go's filosofie dat code van links naar rechts gelezen moet worden, net als een natuurlijke zin.

Als meerdere parameters hetzelfde type hebben, kun je dit verkorten:

func optellen(a, b int) int {
    return a + b
}

Parameters en arguments

Parameters worden standaard by value doorgegeven. Dat betekent dat Go een kopie maakt van de waarde. Wil je een variabele kunnen aanpassen binnen de functie? Dan gebruik je een pointer.

func verdubbel(x *int) {
    *x = *x * 2
}

getal := 5
verdubbel(&getal)
// getal is nu 10

Voor slices, maps en channels hoef je geen pointer te gebruiken: die zijn intern al referentie-achtig. Dit is een veelgemaakte fout bij beginners die uit talen komen waar alles een object-referentie is.

Multiple return values: Go's superpower

Waar veel talen één return value ondersteunen en tuples nodig hebben voor meerdere, heeft Go dit native ingebouwd. Dit is geen gimmick, het vormt de basis van heel het error handling model.

func deel(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("delen door nul")
    }
    return a / b, nil
}

De aanroepende code gebruikt beide waarden:

resultaat, err := deel(10, 2)
if err != nil {
    log.Fatal(err)
}
fmt.Println(resultaat)

Named return values

Go laat je return values een naam geven. Dit werkt als een soort voorgedefinieerde variabele binnen de functie.

func splits(volledig string) (voornaam, achternaam string) {
    delen := strings.SplitN(volledig, " ", 2)
    voornaam = delen[0]
    if len(delen) > 1 {
        achternaam = delen[1]
    }
    return
}

De return zonder argumenten heet een naked return. Handig voor korte functies, maar gebruik het met mate, in langere functies wordt de code moeilijker te volgen.

Variadic functions

Soms weet je niet vooraf hoeveel argumenten een functie krijgt. Denk aan fmt.Println of een eigen log-helper. Met het ... syntax maak je een variadic function.

func som(getallen ...int) int {
    totaal := 0
    for _, g := range getallen {
        totaal += g
    }
    return totaal
}

som(1, 2, 3)           // 6
som(1, 2, 3, 4, 5)     // 15

Binnen de functie gedraagt getallen zich als een slice van int. Wil je een bestaande slice doorgeven? Gebruik dan som(mijnSlice...) met drie puntjes achter de variabele.

Functions als first-class citizens

In Go zijn functies gewone waarden. Je kunt ze aan variabelen toewijzen, als parameter doorgeven en als return value terugsturen. Dit opent de deur naar functionele patronen zoals higher-order functions.

func pasToe(getallen []int, bewerking func(int) int) []int {
    resultaat := make([]int, len(getallen))
    for i, g := range getallen {
        resultaat[i] = bewerking(g)
    }
    return resultaat
}

verdubbeld := pasToe([]int{1, 2, 3}, func(x int) int {
    return x * 2
})

Anonieme functies (closures) kunnen variabelen uit hun omgeving vasthouden. Dit is krachtig voor bijvoorbeeld middleware of event handlers, maar let op geheugengebruik bij langlopende processen.

Error handling: expliciet boven impliciet

Dit is waar Go écht afwijkt van veel andere talen. Er is geen try/catch, geen exceptions die door de callstack bubbelen. In plaats daarvan retourneert elke functie die kan falen een error als laatste return value.

De error interface

Een error in Go is simpelweg een interface met één methode:

type error interface {
    Error() string
}

Dat betekent dat elk type dat een Error() string methode heeft, als error gebruikt kan worden. Meer over hoe interfaces werken lees je in ons artikel over structs en interfaces.

Het if err != nil pattern

Je zult deze drie regels code honderden keren schrijven in een Go project:

resultaat, err := doeIets()
if err != nil {
    return nil, err
}

Ja, het is repetitief. Nee, dat is geen bug, het is een feature. Elke foutpad is zichtbaar, je kunt niet per ongeluk een error negeren, en code reviews worden makkelijker omdat de controleflow expliciet is.

Errors aanmaken

Er zijn meerdere manieren om een error te creëren:

// Eenvoudig via errors.New
err1 := errors.New("bestand niet gevonden")

// Met formatting via fmt.Errorf
err2 := fmt.Errorf("gebruiker %s bestaat niet", naam)

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}

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

Custom error types zijn vooral handig wanneer je calling code wilt laten reageren op specifieke error-situaties.

Error wrapping: context zonder verlies

Sinds Go 1.13 kun je errors wrappen met %w in fmt.Errorf. Dit voegt context toe zonder de originele error te verliezen.

func leesConfig(pad string) (*Config, error) {
    data, err := os.ReadFile(pad)
    if err != nil {
        return nil, fmt.Errorf("config lezen van %s: %w", pad, err)
    }
    // ...
}

Later in de callstack kun je met errors.Is en errors.As de oorspronkelijke error inspecteren:

if errors.Is(err, os.ErrNotExist) {
    // bestand bestaat niet, gebruik defaults
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    log.Printf("padfout op %s", pathErr.Path)
}

Dit pattern is cruciaal voor productiecode. Meer achtergrond vind je in de officiële Go blog post over error handling.

Sentinel errors en error types

Naast gewone errors kun je sentinel errors definiëren: vaste error-waarden die callers kunnen herkennen.

var ErrGebruikerNietGevonden = errors.New("gebruiker niet gevonden")

func zoekGebruiker(id int) (*User, error) {
    if id == 0 {
        return nil, ErrGebruikerNietGevonden
    }
    // ...
}

Callers kunnen dan precies op deze error checken:

user, err := zoekGebruiker(id)
if errors.Is(err, ErrGebruikerNietGevonden) {
    // toon 404
}

Een bekend voorbeeld is sql.ErrNoRows uit de standard library. Gebruik sentinels spaarzaam, ze creëren een publieke API waarop code kan leunen.

Panic en recover: de noodrem

Voor situaties waarin verdergaan geen zin heeft, bestaat panic. Dit stopt de normale uitvoering en unwinds de stack. Met recover (binnen een defer) kun je een panic opvangen.

func veiligeOperatie() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic hersteld: %v", r)
        }
    }()
    // potentieel gevaarlijke code
    return nil
}

Belangrijk: gebruik panic niet voor gewone fouten. Reserveer het voor programmeerfouten (zoals een onmogelijke state) of het opstarten van een applicatie. Voor gewone foutafhandeling gebruik je altijd het error pattern.

Defer: cleanup gegarandeerd

defer zet een functie-aanroep uit tot het moment dat de omringende functie terugkeert, ook als er een panic optreedt. Dit is perfect voor resource cleanup.

func leesBestand(pad string) ([]byte, error) {
    f, err := os.Open(pad)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    return io.ReadAll(f)
}

Meerdere defer statements worden in LIFO-volgorde uitgevoerd. Denk aan het als een stack: de laatste defer loopt als eerste.

Best practices voor error handling

Een paar vuistregels die je code professioneler maken:

  • Handle errors direct: laat ze niet in variabelen rondslingeren
  • Voeg context toe bij het doorgeven met fmt.Errorf("... %w", err)
  • Log één keer: op het hoogste niveau waar je de error afhandelt
  • Gebruik errors.Is en errors.As in plaats van string-vergelijkingen
  • Vermijd naakte panic in library code, laat de caller beslissen

Voor wie dieper wil graven, is de Effective Go gids verplichte kost. Hierin staat hoe de Go-community zelf denkt over idiomatische foutafhandeling.

Praktijkvoorbeeld: alles samen

Laten we alles combineren in een realistisch voorbeeld:

var ErrOngeldigBedrag = errors.New("bedrag moet positief zijn")

func verwerkBetaling(ctx context.Context, bedrag float64) (string, error) {
    if bedrag <= 0 {
        return "", ErrOngeldigBedrag
    }

    txID, err := startTransactie(ctx, bedrag)
    if err != nil {
        return "", fmt.Errorf("transactie starten voor %.2f: %w", bedrag, err)
    }
    defer cleanupTransactie(txID)

    if err := bevestigBetaling(ctx, txID); err != nil {
        return "", fmt.Errorf("betaling bevestigen %s: %w", txID, err)
    }

    return txID, nil
}

Dit toont het complete plaatje: validatie, wrapping, defer voor cleanup, en duidelijke return paths. Geen verborgen exceptions, geen verrassingen.

Hoe nu verder

Functions en error handling vormen het skelet van elke Go-applicatie. Als je deze concepten beheerst, kun je serieuze backend-code schrijven. Begin met kleine projecten: een CLI tool, een HTTP handler, een simpele parser. De Go by Example website is een uitstekende bron voor korte, werkende voorbeelden.

Wil je weten waarom Go zo geliefd is bij backend-developers? Lees dan waarom Go populair is voor backends. Nog niet gestart? Check dan eerst installatie en eerste Go programma.

Veelgestelde vragen

Waarom heeft Go geen try/catch zoals andere talen?

Go kiest bewust voor expliciete error handling via return values. Dit maakt foutpaden zichtbaar in de code, voorkomt verborgen exceptions en dwingt ontwikkelaars om bewust na te denken over wat er mis kan gaan. Het resultaat is voorspelbaardere en beter onderhoudbare code.

Wat is het verschil tussen error en panic in Go?

Een error is een verwachte fout die je netjes afhandelt, zoals een bestand dat niet bestaat of ongeldige input. Een panic is voor onverwachte situaties waarbij het programma niet verder kan, zoals een nil pointer dereference. In productiecode gebruik je bijna altijd errors, zelden panics.

Kan een Go functie meerdere waarden teruggeven?

Ja, Go ondersteunt multiple return values standaard. Dit wordt veel gebruikt om zowel een resultaat als een error terug te geven, bijvoorbeeld result, err := doeIets(). Je kunt zoveel waarden teruggeven als nodig, al is meer dan drie vaak een teken dat je een struct moet gebruiken.

Moet ik altijd errors.Is of errors.As gebruiken?

Gebruik errors.Is wanneer je wilt controleren op een specifieke sentinel error zoals sql.ErrNoRows. Gebruik errors.As wanneer je een error wilt uitpakken naar een specifiek type om meer details te krijgen. Voor eenvoudige checks volstaat vaak if err != nil.

Wat betekent het om een error te wrappen?

Error wrapping voegt context toe aan een oorspronkelijke error zonder de informatie te verliezen. Met fmt.Errorf("context: %w", err) behoud je de originele error, zodat je later met errors.Is of errors.As de root cause kunt achterhalen. Dit maakt debuggen in productie een stuk makkelijker.

Veelgestelde vragen

Klaar om digitaal te groeien?

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