27. Constructors, validation and struct design

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

Go has no new Person() and no constructor keyword. What it has is a convention — a plain function named New... that returns a ready-to-use value — plus a strong preference for making the zero value work in the first place.

The New convention

package main

import "fmt"

type Server struct {
    Host    string
    Port    int
    Timeout int
}

func NewServer(host string) *Server {
    return &Server{
        Host:    host,
        Port:    8080,
        Timeout: 30,
    }
}

func main() {
    s := NewServer("localhost")
    fmt.Printf("%+v\n", *s)

    s.Port = 9090
    fmt.Printf("%+v\n", *s)
}

That's the entire pattern: an ordinary function that fills in defaults and returns a pointer. Nothing enforces it — you can still write &Server{Host: "x"} yourself — so a constructor in Go is a convenience, not a gate.

Naming convention: NewX in a package that offers several types (bytes.NewReader), or plain New when the package has one obvious type (errors.New, list.New). A package called server with a Server type gives you server.New() — Go dislikes stutter like server.NewServer().

Constructors that can fail

When construction needs validation, return the (value, error) pair you met in the functions module:

package main

import (
    "errors"
    "fmt"
)

type User struct {
    Name  string
    Email string
    Age   int
}

func NewUser(name, email string, age int) (*User, error) {
    if name == "" {
        return nil, errors.New("name is required")
    }
    if age < 0 || age > 150 {
        return nil, fmt.Errorf("age %d is out of range", age)
    }
    return &User{Name: name, Email: email, Age: age}, nil
}

func main() {
    u, err := NewUser("Ada", "ada@example.com", 36)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("%+v\n", *u)

    if _, err := NewUser("", "x@example.com", 20); err != nil {
        fmt.Println("error:", err)
    }
    if _, err := NewUser("Bob", "b@example.com", 200); err != nil {
        fmt.Println("error:", err)
    }
}

Now an invalid User can't come out of NewUser. If the type also lives in its own package with unexported fields, NewUser becomes the only way to build a valid one — that's how you enforce invariants in Go.

fmt.Errorf is Sprintf for errors: same formatting verbs, produces an error.

Prefer a useful zero value

Before you write a constructor, ask whether you need one:

package main

import (
    "fmt"
    "strings"
)

type Buffer struct {
    parts []string
}

func (b *Buffer) Add(s string) {
    b.parts = append(b.parts, s)
}

func (b *Buffer) String() string {
    return strings.Join(b.parts, " ")
}

func main() {
    var b Buffer
    b.Add("no")
    b.Add("constructor")
    b.Add("needed")
    fmt.Println(b.String())
}

var b Buffer is immediately usable because a nil slice appends fine. This is why sync.Mutex, bytes.Buffer and strings.Builder have no constructors — declare one and start using it.

The design instinct: pick field types whose zero values already mean the right thing. A map field is the common exception, since a nil map panics on write — that alone is often reason enough for a constructor.

Functional options for many settings

When a type has a lot of optional configuration, Go's answer to keyword arguments is a variadic list of option functions:

package main

import "fmt"

type Client struct {
    BaseURL string
    Retries int
    Verbose bool
}

type Option func(*Client)

func WithRetries(n int) Option {
    return func(c *Client) { c.Retries = n }
}

func WithVerbose() Option {
    return func(c *Client) { c.Verbose = true }
}

func NewClient(url string, opts ...Option) *Client {
    c := &Client{BaseURL: url, Retries: 3}
    for _, opt := range opts {
        opt(c)
    }
    return c
}

func main() {
    a := NewClient("https://api.example.com")
    fmt.Printf("%+v\n", *a)

    b := NewClient("https://api.example.com", WithRetries(10), WithVerbose())
    fmt.Printf("%+v\n", *b)
}

Everything you've learned in the last two modules is in that snippet: a named function type, closures capturing a parameter, variadic arguments, and a pointer so the options can mutate the value being built.

It's readable at the call site (WithRetries(10) says what it does), it extends without breaking anyone, and it's what you'll find in serious Go libraries. Don't reach for it with two fields — reach for it when a config struct starts sprouting booleans.

Copying and clone methods

Because assignment copies a struct, "duplicate this" is usually free — but only one level deep:

package main

import "fmt"

type Profile struct {
    Name string
    Tags []string
}

func (p Profile) Clone() Profile {
    tags := make([]string, len(p.Tags))
    copy(tags, p.Tags)
    return Profile{Name: p.Name, Tags: tags}
}

func main() {
    a := Profile{Name: "Ada", Tags: []string{"go", "math"}}

    shallow := a
    shallow.Name = "Copy"
    shallow.Tags[0] = "MUTATED"

    fmt.Printf("%+v\n", a)

    b := a.Clone()
    b.Tags[0] = "isolated"
    fmt.Printf("%+v\n", a)
    fmt.Printf("%+v\n", b)
}

shallow := a copied the Name but shared the Tags slice — so writing through shallow.Tags changed a. Same slice-sharing rule from module 4, now hiding inside a struct.

If your type contains a slice, map or pointer and callers might mutate it, give it an explicit Clone that copies those fields properly.

Your turn

Write NewRectangle(w, h float64) (*Rectangle, error) that rejects non-positive dimensions with the message dimensions must be positive, and otherwise returns the rectangle. The program should print:

area: 12.00
error: dimensions must be positive
package main

import (
    "errors"
    "fmt"
)

type Rectangle struct {
    Width  float64
    Height float64
}

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

// write NewRectangle here

func main() {
    r, err := NewRectangle(3, 4)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("area: %.2f\n", r.Area())

    if _, err := NewRectangle(-1, 4); err != nil {
        fmt.Println("error:", err)
    }
}
package main

import (
    "errors"
    "fmt"
)

type Rectangle struct {
    Width  float64
    Height float64
}

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

func NewRectangle(w, h float64) (*Rectangle, error) {
    if w <= 0 || h <= 0 {
        return nil, errors.New("dimensions must be positive")
    }
    return &Rectangle{Width: w, Height: h}, nil
}

func main() {
    r, err := NewRectangle(3, 4)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("area: %.2f\n", r.Area())

    if _, err := NewRectangle(-1, 4); err != nil {
        fmt.Println("error:", err)
    }
}

You can now define types and give them behaviour. Next: the feature that ties Go together — interfaces.