skip to content

Importers already set a struct field your Go package exported by accident — how do you take it back?

level: seniorimportance: should knowfreq 36%

answer

  1. first ask who your importers are
  2. the compiler will find every call site
  3. no property interception in this language
  4. a field and a method cannot share a name
  5. read go doc -all as the contract

basics

~20 s

Lower-casing the name breaks every importer at compile time, so do it in place only when you can rebuild them all in one change. Otherwise add the replacement API, mark the field deprecated, and remove it at the next major version.

solid answer

~50 s

First decide who your importers are. Inside one repository you can rebuild, unexporting is the best migration Go offers: the compiler points at every call site, and there is no silent behaviour change to chase. For a published module, renaming the field is a breaking change, which under Go's module rules means a new major version and a `/v2` suffix on the module path, so you stage it instead: add the method or constructor that expresses what callers actually needed, mark the field deprecated in its doc comment, and drop it when you next take a major version. Note that you cannot smuggle in validation by turning the field into a method of the same name, because a struct field and a method cannot share a name. And the reason there is no clever third option is that Go has no property interception: a field is a memory slot, and the only way to make code run is to make callers call something.

code

go · 6 lines
go
type Client struct {
	Timeout time.Duration
}

// compile error: field and method with the same name Timeout
func (c *Client) Timeout() time.Duration { return c.Timeout }

go deeper

for a junior

Know that lower-casing a name that other packages already use will stop their code compiling, so exporting a name is a decision, not a formatting choice.

for a middle

Be ready to explain why validation cannot be bolted onto an exported field, including the rule that a field and a method cannot share a name on the same type.

for a senior

Ask who the importers are before choosing, then justify either an atomic unexport-and-fix inside one build, or add-deprecate-remove for consumers who upgrade on their own schedule.

for a principal

Own the prevention: make the exported surface a reviewed artefact of every release, and decide the organisation's policy on when a major version is worth spending to reclaim a name.

## Why this is expensive in Go specifically Assigning a struct field executes no code. There is no property, no `__setattr__`, no setter the compiler will route through. So once a field is exported, the only way to attach behaviour to it — validation, normalisation, recomputing a cached value, recording that it changed — is to stop callers from touching it and make them call a method. That is a rename, and a rename is a compile break for every importer. The trick people reach for first does not work either: you cannot keep the name and turn it into a method, because a Go struct may not have a field and a method with the same name. `Timeout` the field and `Timeout()` the method cannot coexist on one type. ## The options, in the order you should consider them **1. Unexport it now, and fix the call sites in the same change.** If every importer is code you can build — one repository, one release train — this is the cleanest outcome available in any language. The compiler enumerates the breakage for you, there is no runtime surprise, and the migration is done when the build is green. Do not talk yourself out of this because it "feels breaking"; the alternative is carrying the field forever. **2. Add the supported API, deprecate the field, remove it at the next major version.** When importers upgrade independently, you cannot remove the name in place. Introduce whatever they actually need — a constructor parameter, a functional option, a setter method with a distinct name — and mark the field with a `// Deprecated:` note saying what to use instead, which tooling and pkg.go.dev surface to callers. The removal then rides the next major version; for a Go module, a major version above 1 means the module path itself gains a `/v2` suffix, and importers move deliberately. **3. Accept it.** Sometimes the field is fine and the discomfort is aesthetic. If callers setting it directly is harmless, document it as supported and move on. A surface you keep on purpose costs less than a migration nobody needed. What is not an option is a doc comment saying "internal, do not use". It changes nothing that the compiler checks, and once code depends on the field, breaking it later is just as expensive as breaking anything else you exported. ## Catching it before it ships `go doc -all ./yourpkg` prints the package exactly as an importer sees it. Read it as the contract, not as documentation. The names that get exported by accident are predictable: - fields on a config or option struct that were convenient for the package's own test - a method that exists for one internal call site but sits on an exported type - names promoted into an exported struct from an embedded exported type, which never appear in your own source as exported declarations - a type that becomes public simply because an exported function returns it, or takes it as a parameter - exported constants used only for internal state machines A release checklist item as small as "diff `go doc -all` against the previous tag" catches most of this, and the review of that diff is a different, more useful conversation from the review of the implementation. ## The judgment to show Say who the importers are before you say what you would do. "Internal to our monorepo, so unexport it and fix the callers in one commit" and "published to other teams on their own schedule, so deprecate and remove at v2" are both right answers to different questions, and the failure mode in interviews is giving one of them without asking which situation you are in.

  • How do you catch an accidentally exported name before you tag a release?
    Read `go doc -all ./yourpkg` as the contract and diff it against the previous tag. Look hardest at fields on config structs, methods on exported types that exist for one internal caller, names promoted from an embedded exported type, and types dragged into the surface because an exported function returns them.
  • Why can't you keep the field name and just make it validate?
    Because a field assignment in Go runs no code, and you cannot replace the field with a method of the same name: a struct may not declare a field and a method sharing one name. The only enforcement point is a differently named method, which means callers change either way.
  • What if importers only read the field and never set it?
    It is still a rename for them, since the accessor cannot reuse the name, so the compile break is the same size. The migration is easier only in that a read has no invariant to preserve: you add an exported accessor, deprecate the field, and remove it when you take the next major version.
  • Is unexporting in a monorepo really the better option?
    Usually yes. Every consumer is code you build, so the compiler enumerates the work, the change lands atomically, and nothing silently keeps compiling against a stale contract. The deprecate-and-wait dance exists for consumers you cannot rebuild; paying its cost when you can rebuild them is choosing a slower migration for no benefit.

saying these in an interview costs you the question

  • Assumes lower-casing an exported name is a safe non-breaking change
  • Expects importers to see the change at run time rather than at compile time
  • Plans to replace the field with a method of the same name
  • Thinks a do-not-use doc comment prevents dependence
  • Never asks whether the importers are rebuildable before choosing