skip to content

When is adding a field to an exported Go struct safe for importers, and when does it break them?

level: middleimportance: should knowfreq 40%

answer

  1. who is allowed to write pkg.T{1, 2}
  2. one unexported field changes the rules
  3. keyed literals survive anything
  4. can the struct still be a map key
  5. the zero value must mean unset

basics

~20 s

Safe when the struct already has an unexported field, because importers must then write keyed literals. It breaks unkeyed positional literals of all-exported structs, and it breaks == and map-key use when the new field's type is not comparable.

solid answer

~50 s

The usual answer is that adding a field is additive, and it usually is — keyed literals like `pkg.Client{Addr: a}` keep compiling no matter how many fields appear. The trap is the *unkeyed* literal `pkg.Client{a, 5}`, which lists every field positionally and stops compiling the moment you add one. You can only be written that way from another package if the struct has no unexported fields, so one unexported field (or a `_ struct{}` placeholder) permanently forces keyed literals and makes field additions safe. Two other breaks are real: if the new field's type is not comparable — a slice, map or func — the struct type itself becomes non-comparable, so downstream `==` comparisons and map keys fail to compile; and if the struct is embedded downstream, a new field name can collide at the same depth and make a selector ambiguous.

code

go · 13 lines
go
// Every field exported: pkg.Point{1, 2} compiles in an importer,
// and stops compiling the day a Z field is added.
type Point struct {
	X int
	Y int
}

// One unexported field: an importer must write
// pkg.Client{Addr: "h:1"}, so fields may be added freely.
type Client struct {
	Addr string
	conn net.Conn
}

go deeper

for a junior

Know both composite-literal forms and be able to say which one a new field breaks: the positional pkg.T{1, 2} form, never the keyed pkg.T{X: 1} form.

for a middle

Explain the mechanism — an unexported field makes a positional literal impossible from another package — and know that a slice or map field makes the whole struct non-comparable.

for a senior

Show the habit of designing for it before you need it: a guard field on structs you expect to grow, and a zero value that preserves existing behaviour when the field appears.

for a principal

Decide what your organisation's exported structs promise at all — comparability, embeddability, literal construction — since each promise is one you cannot withdraw without a break.

## The composite-literal rule Go lets you write a struct literal two ways: ```go c := pkg.Client{Addr: "h:1", Retries: 3} // keyed c := pkg.Client{"h:1", 3} // unkeyed, positional ``` The keyed form names the fields it sets and zero-values the rest, so it is indifferent to new fields being added: it compiles today and it compiles after your next release. The unkeyed form must supply **every** field in declaration order. Add a field and it fails with "too few values in struct literal". That is the single most common way a supposedly additive change breaks an importer. ## Why an unexported field makes the addition safe An unkeyed literal written in another package would have to assign a value to every field, including unexported ones — and unexported identifiers are inaccessible outside their package. The compiler rejects it ("implicit assignment to unexported field"). So the presence of even one unexported field means **no importer can write an unkeyed literal of your struct at all**, and adding fields is then guaranteed not to break literals. That is why the idiom exists of putting a deliberately empty guard field in an otherwise all-exported struct: ```go type Options struct { Retries int Verbose bool _ struct{} // forces keyed literals from other packages } ``` The zero-size `_ struct{}` costs nothing at runtime and buys you the right to grow the struct later. `go vet` also reports unkeyed composite literals of imported struct types, which catches the pattern in a downstream codebase before your next release does. Note what the unexported field does *not* buy: it does not stop the importer from reading and writing the exported fields, and it does not stop your own package from writing unkeyed literals internally, which is where such literals usually hide. ## Comparability: the break people miss A struct type is comparable with `==` only if every one of its field types is comparable. Add a `[]string`, a `map[string]int` or a `func()` field, and the whole struct type becomes non-comparable. That is a compile-time break in any importer that wrote: ```go if got == want { ... } // now: invalid operation, ... cannot be compared seen := map[pkg.Key]bool{} // now: invalid map key type ``` Nothing in the change looks breaking — you added a field, you removed nothing — and the failure appears in code you have never read. If a type of yours is plausibly used as a map key or compared, treat its comparability as part of the published contract, and if you must carry a slice, carry it behind a pointer to an inner struct or keep it in a separate type. ## Embedding and ambiguous selectors If a downstream type embeds your struct, your field names leak into its selector namespace. Go resolves a selector by shallowest depth; a name at the outer level shadows yours, so that case is fine. The break is when a downstream type embeds **two** types that, after your change, both have a field of that name at the same depth: the selector becomes ambiguous and no longer compiles. This is rare but it is the reason field names on an embeddable struct deserve more thought than field names on one that is only ever used directly. ## Interface satisfaction is unaffected A field is not a method, so adding one never changes which interfaces your type satisfies. That asymmetry is worth stating plainly: on a struct, **fields are cheap to add and methods are almost always cheap to add**; the expensive additions are to interfaces, not to structs. ## The checklist before you add a field 1. Does the struct have at least one unexported field? If not, add a `_ struct{}` guard now, in the same release, before you need it. 2. Is the new field's type comparable? If not, could importers be comparing this struct or using it as a map key? 3. Is the struct designed to be embedded? If so, is the name likely to collide? 4. Does the new field have a sane zero value? Every existing keyed literal will get the zero value, so the field must mean "unset" at zero, or you have silently changed the behaviour of every caller who does not know it exists. Point 4 is the one that bites in production rather than at compile time. A new `MaxRetries int` field defaults to 0 for every existing caller; if your code reads it as "retry that many times", you have just turned retries off for everyone who upgrades. The compile-time rules are only half the compatibility question — the other half is whether the zero value preserves the old behaviour.

  • Your exported constructor takes three parameters and needs a fourth tunable. What shape lets you add it without touching callers?
    Make the constructor variadic over an option type — `func New(addr string, opts ...Option) (*Client, error)` — where each `Option` is a function that mutates the internal config. The signature never changes again, so every existing call keeps compiling, and the tenth option costs callers nothing. Adding a plain fourth parameter breaks every call site in every importer.
  • How would you detect an unkeyed literal problem before your users do?
    `go vet` reports unkeyed composite literals of imported struct types, so run it over a real consumer, not just over the library. Better, remove the hazard structurally: give any exported struct you expect to grow an unexported or `_ struct{}` field, which makes the positional form impossible from outside your package in the first place.
  • Why can a purely additive field still change behaviour for existing callers?
    Existing keyed literals and zero-valued structs get the field's zero value. If the code reads that field as a meaningful setting — a limit, a count, a mode — every caller who upgrades silently gets the zero-value behaviour. The new field must either mean "unset" at zero or be normalised in a constructor before use.

saying these in an interview costs you the question

  • Says adding a struct field is always a safe, additive change
  • Does not know unkeyed composite literals exist
  • Thinks an unexported field hides the exported ones from importers
  • Misses that a slice or map field destroys comparability
  • Ignores that the new field's zero value applies to every existing caller