skip to content

Why would a Go package keep a struct's fields unexported and hand callers an exported constructor instead?

level: middleimportance: should knowfreq 50%

answer

  1. who is allowed to build one
  2. a field assignment runs no code in Go
  3. the constructor is the single enforcement point
  4. but the zero value still escapes you

basics

~20 s

To control how values are built and how they are represented. With the fields unexported, importers cannot set them, so the constructor becomes the only place invariants can be established, and the field layout can change later without breaking callers.

solid answer

~50 s

Unexported fields plus an exported `New` function move the package's invariants into one place. An importer cannot assign the fields, cannot write a positional composite literal for the type, and cannot read internal state, so whatever `New` guarantees stays guaranteed, and you are free to change the field set later because no caller names it. What it does *not* do is prevent the type from existing: `pkg.User{}` and `var u pkg.User` still compile and produce the zero value, so either make the zero value usable or have methods detect it. The cost is real too: importers cannot build fixtures field by field, cannot mutate what you did not expose a method for, and reflection-driven code sees nothing. So reserve the pattern for types with invariants; for plain data, exporting the fields is the cheaper and more idiomatic choice.

code

go · 14 lines
go
type User struct {
	id    int64
	email string
	tags  map[string]string
}

func New(id int64, email string) (*User, error) {
	if id <= 0 || !strings.Contains(email, "@") {
		return nil, fmt.Errorf("user: invalid id or email")
	}
	return &User{id: id, email: email, tags: map[string]string{}}, nil
}

func (u *User) Email() string { return u.email }

go deeper

for a junior

Know that lower-case fields cannot be set by an importer and that a New function is the usual way a package hands you a ready-to-use value.

for a middle

Be ready to explain what the pattern guarantees and what it does not: no code runs on field assignment, so the constructor is the only enforcement point, yet the zero value is still reachable from anywhere.

for a senior

Show judgment about when to pay the cost: unexported state for types with invariants, exported fields for plain data, and a plan for what the zero value means either way.

for a principal

Own the API consequence: unexported fields mean every future piece of caller-supplied state has to arrive through a parameter or option you design and support forever.

## The pattern ```go package user type User struct { id int64 email string tags map[string]string } func New(id int64, email string) (*User, error) { if id <= 0 || !strings.Contains(email, "@") { return nil, fmt.Errorf("user: invalid id or email") } return &User{id: id, email: email, tags: map[string]string{}}, nil } func (u *User) Email() string { return u.email } ``` Every field is lower-case, so an importing package cannot name any of them. The only supported way in is `user.New`, and that function is therefore the single place where "an id is positive and an email contains an @" becomes true. ## What unexported fields actually buy you **One enforcement point.** Go has no property syntax: a field is a memory slot, and assigning to it runs no code. If you want validation, normalisation, or a lazily built map to be guaranteed, the field has to be unreachable and the work has to happen in a function. That is the whole reason the constructor exists. **Freedom to change representation.** No importer names `email`, so you can split it into `local` and `domain`, replace the map with a slice, or add a cached value, and every consumer still compiles. An exported field, by contrast, is part of your contract from the moment you ship it. **No half-built values from outside.** Because at least one field is unexported, an importer cannot write a positional literal such as `user.User{1, "[email protected]", nil}`; the compiler rejects the implicit assignment to an unexported field. A keyed literal naming an unexported field is rejected for the same reason. ## What it does not buy you **It does not stop the type being instantiated.** `var u user.User` and `u := user.User{}` are both legal from any package: the empty composite literal sets no unexported field, so nothing is being violated as far as the compiler is concerned. What the importer gets is the zero value, with an id of 0 and a nil map. Packages handle this in one of three ways: make the zero value genuinely useful (the way `bytes.Buffer` and `sync.Mutex` do), have methods return an error or panic on an obviously unbuilt value, or return `*User` from `New` so that the natural way to hold one is a pointer that is either valid or nil. **It does not stop copying.** Assignment copies the whole struct, unexported fields included, from any package. If a copy must not happen — because the value carries a mutex or a pointer that must stay unique — the compiler will not help you; a `go vet` copylocks warning does, when a lock is embedded. **It does not stop reflection reading structure.** Reflection can see that unexported fields exist and read their types, but `reflect.Value.Interface` on an unexported field panics and `CanSet` reports false, so code cannot set them for you. Practically: reflection-driven libraries, encoders included, ignore unexported fields entirely. ## The cost, which is why this is a judgment and not a rule An importing package can no longer build a value field by field. That hurts most in *their* tests, where a fixture with a specific internal state is exactly what they want; they are now limited to whatever your constructor and setters allow. It also means every new piece of state you want callers to supply becomes another parameter, another option, or another method on your surface. So the honest split is: if the type has an invariant that must hold, unexport the fields and own construction. If the type is plain data — a request payload, a config struct, a row — export the fields. Go's own library does both: `http.Client` and `http.Server` are exported-field configuration structs with a usable zero value, while `time.Time`, `strings.Builder` and `sync.WaitGroup` keep their state unexported because it means nothing outside the methods. ## Naming The constructor convention is `New` when the package name already says what is being made (`user.New`), and `NewThing` when a package builds several types (`http.NewRequest`). Returning `(*T, error)` is the norm when construction can fail; returning `*T` alone says it cannot.

  • Does making every field unexported stop an importer from creating a value of the type at all?
    No. `var u pkg.User` and `pkg.User{}` still compile from any package, because an empty composite literal sets no unexported field. They get the zero value with none of your invariants. Either make the zero value meaningful, have methods detect it, or return a pointer from the constructor so the usual way to hold one is nil or valid.
  • What do callers lose when you unexport all the fields?
    The ability to build a value field by field, which bites hardest in their own tests where a specific internal state is the point. They also lose direct mutation, and any reflection-driven code of theirs sees an empty struct. Every piece of state you later want them to supply has to become a parameter, an option or a method.
  • When is exporting the fields the better call?
    When the type is plain data with no invariant to defend: a config struct, a decoded payload, a row. `http.Client` and `http.Server` are exported-field structs with a usable zero value, and that makes them pleasant to use. Unexport when the state means nothing outside the methods, as with `time.Time` or `strings.Builder`.

Unexported fields with an exported constructor is a factory gate: the only way to get a finished item is through the gate, where it gets inspected. It does not stop someone picking up the empty box that the item ships in.

saying these in an interview costs you the question

  • Says unexported fields make the type impossible to construct outside the package
  • Adds a getter and setter for every field out of habit
  • Believes copying the value from another package skips unexported fields
  • Claims reflection can set unexported fields for a caller
  • Thinks the constructor prevents the zero value from ever existing