Skip to content
gohacks.dev
Basics

Constructor Functions in Go and the New Convention

Go has no constructors, only functions named New that return a ready value. See the naming rules, validation with errors and when to skip one.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

Go has no constructor keyword, no new on a type and no initializer blocks. A struct is created with a literal, and the compiler fills every field with its zero value. When that is not enough, the community convention is a plain function that returns a ready value, and the convention is so consistent that you can predict the name before you read the documentation. This how-to covers the naming rules, the shape of a good constructor, the validation question that decides whether it returns an error and the cases where you should not write one at all. It builds on structs and the useful zero value, leads into functional options, and sits in the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

A constructor that validates

This server type has a required address, an optional timeout and a dependency that should not be nil. The constructor checks them, applies a default, and returns an error for bad input:

main.goGo
package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"time"
)

type Server struct {
	addr    string
	timeout time.Duration
	logger  *slog.Logger
}

const DefaultTimeout = 30 * time.Second

func NewServer(addr string, timeout time.Duration, logger *slog.Logger) (*Server, error) {
	if strings.TrimSpace(addr) == "" {
		return nil, errors.New("server: address is required")
	}
	if timeout < 0 {
		return nil, fmt.Errorf("server: timeout %v is negative", timeout)
	}
	if timeout == 0 {
		timeout = DefaultTimeout
	}
	if logger == nil {
		logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
			ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
				if a.Key == slog.TimeKey {
					return slog.Attr{}
				}
				return a
			},
		}))
	}
	return &Server{addr: addr, timeout: timeout, logger: logger}, nil
}

func (s *Server) Describe() string {
	return fmt.Sprintf("%s (timeout %v)", s.addr, s.timeout)
}

func main() {
	s, err := NewServer("localhost:8080", 0, nil)
	fmt.Println(s.Describe(), err)
	s.logger.Info("ready", "addr", s.addr)

	_, err = NewServer("", time.Second, nil)
	fmt.Println(err)

	_, err = NewServer("localhost:1", -time.Second, nil)
	fmt.Println(err)
}
Text
localhost:8080 (timeout 30s) <nil>
level=INFO msg=ready addr=localhost:8080
server: address is required
server: timeout -1s is negative

The valid call got the default timeout, 30s, because a zero timeout is documented as "use the default", and a logger was supplied for it, since the caller passed nil. The two invalid calls returned errors and no server, which is the property that matters: an invalid *Server never exists, so no method needs to defend against one. The error messages start with the package or type name and use lowercase with no trailing punctuation, matching the naming conventions. The log line used log/slog with the timestamp removed so that the output is the same on each run.

The naming rule

The name follows how many types the package exports. A package with one main type uses New:

PackageConstructorReads as
errorserrors.NewA new error
bufiobufio.NewReader, bufio.NewWriterSeveral types, so the type name is in the function name
listlist.NewThe package's single main type
httphttp.NewRequest, http.NewServeMuxSeveral types
stringsstrings.NewReader, strings.NewReplacerSeveral types

When the package's type is named after the package, as in cache.Cache, the constructor is cache.New() and not cache.NewCache(), since the repeated word is the stutter package naming tells you to avoid. When a constructor takes a different source, the convention is NewFromFile, NewWithConfig or Open for things that acquire a resource, and Parse for building from text.

Returning a pointer or a value

Return *T when the type has identity or state that methods change, a server, a pool, anything with a mutex, as pointer or value receivers explains. Return T for small immutable values: time.Date, url.Values, a Point. The choice affects whether the zero value of the return is meaningful on an error: with *T it is nil, and callers check the error before using the pointer, and with T you return the zero value alongside the error. The measurement in returning a pointer or a value shows that for a small struct the value return is the cheaper one.

Test the validation

The constructor is where invalid states are rejected, so test it with the inputs it should reject:

server.goGo
package server

import (
	"errors"
	"fmt"
	"strings"
	"time"
)

type Server struct {
	addr    string
	timeout time.Duration
}

func New(addr string, timeout time.Duration) (*Server, error) {
	if strings.TrimSpace(addr) == "" {
		return nil, errors.New("server: address is required")
	}
	if timeout < 0 {
		return nil, fmt.Errorf("server: timeout %v is negative", timeout)
	}
	if timeout == 0 {
		timeout = 30 * time.Second
	}
	return &Server{addr: addr, timeout: timeout}, nil
}

func (s *Server) Timeout() time.Duration { return s.timeout }
server_test.goGo
package server

import (
	"testing"
	"time"
)

func TestNew(t *testing.T) {
	cases := []struct {
		name    string
		addr    string
		timeout time.Duration
		want    time.Duration
		wantErr bool
	}{
		{"default timeout", "a:1", 0, 30 * time.Second, false},
		{"explicit timeout", "a:1", 2 * time.Second, 2 * time.Second, false},
		{"missing address", "  ", time.Second, 0, true},
		{"negative timeout", "a:1", -1, 0, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			s, err := New(c.addr, c.timeout)
			if (err != nil) != c.wantErr {
				t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
			}
			if err == nil && s.Timeout() != c.want {
				t.Errorf("timeout = %v, want %v", s.Timeout(), c.want)
			}
		})
	}
}
Text
=== RUN   TestNew
=== RUN   TestNew/default_timeout
=== RUN   TestNew/explicit_timeout
=== RUN   TestNew/missing_address
=== RUN   TestNew/negative_timeout
--- PASS: TestNew (0.00s)
    --- PASS: TestNew/default_timeout (0.00s)
    --- PASS: TestNew/explicit_timeout (0.00s)
    --- PASS: TestNew/missing_address (0.00s)
    --- PASS: TestNew/negative_timeout (0.00s)
PASS
ok  	example.test/lab	1.268s

The table covers the default, an explicit value and the two rejections, and the unexported fields are read through a method. The test for the constructor of a type with many options grows quickly, which is the signal to move to functional options.

Open, Dial and Start: constructors that acquire something

When building the value takes a resource, a file, a network connection, a goroutine, the convention is to say so in the name. os.Open, net.Dial, sql.Open and http.ListenAndServe all return a value that holds something the caller must release, and the matching release method is almost always Close or Stop. The name is a warning, since New implies a cheap, side-effect-free value. Here is the shape in miniature:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type Conn struct {
	addr   string
	closed bool
}

func Dial(addr string) (*Conn, error) {
	if addr == "" {
		return nil, errors.New("dial: empty address")
	}
	fmt.Println("opened", addr)
	return &Conn{addr: addr}, nil
}

func (c *Conn) Close() error {
	if c.closed {
		return errors.New("close: already closed")
	}
	c.closed = true
	fmt.Println("closed", c.addr)
	return nil
}

func main() {
	conn, err := Dial("db.example.test:5432")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer conn.Close()

	_, err = Dial("")
	fmt.Println(err)
}
Text
opened db.example.test:5432
dial: empty address
closed db.example.test:5432

The defer goes right after the error check, so the connection is closed however the function exits, and the failed Dial returns no value to close. The constructor either hands back something ready and owned, or an error and nothing. A zero value of Conn could not be useful, since there is no connection behind it, so the type has no usable zero state and the documentation should say so. Defer and the order things run in covers why the pairing is written this way.

Exported fields with a Validate method

There is a second way to enforce rules, and it suits configuration. Export the fields, let callers fill them in with a literal, and give the type a Validate() error method that the consumer calls once:

Go
type Config struct {
	Addr    string
	Timeout time.Duration
}

func (c Config) Validate() error {
	if c.Addr == "" {
		return errors.New("config: address is required")
	}
	return nil
}

The trade-off is that an invalid Config can exist, in the window between construction and the check, and the benefit is that the type is a plain data structure that decodes from JSON or YAML and can be compared and copied. For a type that is data, this is the better design. For a type with behavior and invariants, the unexported fields and a constructor give a stronger guarantee. Many programs have both: a Config that is data and validated, passed to a New that builds the stateful Server from it. Keep the validation in one place, whichever style you choose, because two copies of a rule drift apart, and a rule that lives in the constructor and in a Validate method is a bug waiting for a change to only one of them.

When not to write one

If every field's zero value is already right, var x T or a literal is the constructor, and a NewT() that returns &T{} is noise. If the only job of the function is to copy arguments into fields, export the fields and use a literal. Constructors also hide the types they build from go doc unless they are exported next to the type, so keep them adjacent in the file. And do not write a constructor that does I/O or starts goroutines without saying so in its name, Open, Start or Dial, because callers expect New to be cheap and side-effect free. The wider design question, when to prefer a type that needs no setup, is in make the zero value useful, and the conventions come from Effective Go on composite literals and constructors and the Go Code Review Comments.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.