skip to content

Your Go library changed an exported type's method signature; the library builds green but consumers break. Why?

level: seniorimportance: nice to knowfreq 34%

answer

  1. no declaration site means no local check
  2. the compiler was never asked
  3. the error lands in someone else's file
  4. pin the intent beside the type
  5. a satisfied interface is public API

basics

~20 s

Nothing in the library records that the type satisfied an interface. Go checks satisfaction only where a value is used as that interface, so if the library never does that, the mismatch first appears when a consumer compiles.

solid answer

~50 s

Implicit satisfaction has no declaration site, so there is nothing for the library's own build to check. Suppose a registry-mirror library exports a type whose `Write(p []byte) (int, error)` consumers rely on as an `io.Writer`, and a refactor changes it to `Write(p []byte) error`. If the library's own code and tests only ever call `Write` directly on the concrete type, `go build ./...` and `go test ./...` both pass — the compiler was never asked whether the type is an `io.Writer`. The break surfaces on the consumer's line, pointing at their code, which also routes the bug report to the wrong team. The fix is to state the intent where the type lives: `var _ io.Writer = (*Store)(nil)` next to the declaration turns the relationship into a use site inside your package, so the build fails at the definition. As a reviewer, treat any interface an exported type is documented to satisfy as part of the public API, and require that assertion.

code

go · 11 lines
go
// Was: consumers passed *Store wherever an io.Writer was wanted.
// func (s *Store) Write(p []byte) (int, error)

// Now: the result list changed. Nothing in this package uses *Store
// as an io.Writer, so the package still builds and tests pass.
func (s *Store) Write(p []byte) error {
	return s.append(p)
}

// Add this beside the type and the refactor fails here instead.
var _ io.Writer = (*Store)(nil)

go deeper

for a junior

Remember that Go only checks interface satisfaction where a value is used as that interface, so a package can lose it without noticing. Know that the blank-identifier assertion exists as the guard.

for a middle

Explain why go build and go test in the library stay green: there is no use site to check, and go vet has no record of the intent. Be able to add the assertion in the right file and say what it turns into a compile error.

for a senior

Diagnose from the consumer's error message back to the dependency's change, and describe the review discipline: an interface an exported type satisfies is public API, so pin it at the definition and release the change as breaking.

for a principal

Decide policy: which of your exported types carry pinned interface contracts, how that is documented, and how a signature change on one is versioned. Weigh the same risk in reverse — that adding a method can silently change consumer behaviour through accidental satisfaction.

## What actually happened Go decides interface satisfaction structurally and checks it **at use sites**: an assignment to an interface-typed variable or field, an argument to a function that takes the interface, a return, or a conversion. If no such site exists inside your module, the compiler is never asked the question, and the relationship simply is not part of your build. So a library can lose a satisfaction it was relying on with a completely green build. The usual shapes: - A method's signature changed — an extra parameter, an error result dropped, `[]byte` narrowed to `string`. - A method was renamed, or its case changed so it is no longer exported. - A method moved from one receiver form to another, or an embedded field that was promoting it was removed. - The interface itself changed on the other side, in a dependency you upgraded. Each one is a compile error somewhere. Just not in your repository. ## Why the tools do not catch it - `go build ./...` type-checks your packages. There is no site to check, so there is nothing to fail. - `go test ./...` only helps if a test happens to use the type as that interface. Tests that call methods on the concrete type do not. - `go vet` reports suspicious constructs; it has no notion of "this type was supposed to be an `io.Writer`", because nothing in the source says so. The intent lives in your documentation and in your head, and none of the toolchain reads either. ## What the consumer sees Their build fails at **their** line — the place where they pass your type to something wanting the interface — with a message naming your type and the mismatching method. It is an accurate error and a misleading one: it points at code that did not change. In practice this arrives as a bug report from a downstream team who have no idea which of their dependencies moved. ## The fix, at the definition Put a compile-time assertion in the file that declares the type: ```go var _ io.Writer = (*Store)(nil) ``` That line is a use site inside your own package, so the compiler now checks on every build of your module. Change `Write`'s signature and your build breaks on that line, before the change is ever tagged. It costs nothing at run time — the blank identifier discards the value and the typed nil constructs nothing. ## The reviewer's rule The deeper point, and the one worth making in an interview: **for an exported type, a satisfied interface is part of the public API even though the language does not record it anywhere.** Consumers write code against it, and removing it is a breaking change exactly like removing an exported method. So when reviewing a pull request that touches an exported type's methods, the questions to ask are: 1. Is this type documented — or widely used — as satisfying some interface? 2. If so, is there an assertion pinning it? If not, add one in this PR rather than filing it. 3. Is the signature change a breaking change to that contract, and is it being released accordingly? For **unexported** types used as an interface within the same package, the assertion is usually redundant — your own code already assigns them, so the compiler already checks. The assertion earns its place precisely where the only use site lives in code you cannot see. ## Related failure worth knowing The mirror image also exists: a type can *accidentally gain* satisfaction. Adding a `String() string` method to a type makes it a `fmt.Stringer`, and `fmt` verbs will start calling it, changing log output and, in the worst case, recursing if the method itself formats the receiver with `%v`. Structural typing cannot distinguish an intended match from a coincidental one, which is why method names in Go carry conventional weight and why adding a method to an exported type is not always the additive, safe change it looks like.

  • Would go vet or a full go test ./... in the library have caught this?
    Neither, unless a test happens to use the type as that interface. `go vet` has no way to know the type was meant to be an `io.Writer`, and tests that call the method directly on the concrete type type-check fine. Only an actual use site — a test one counts — forces the check.
  • Is an interface an exported type satisfies part of the library's public API?
    In effect yes. Consumers write code that depends on it, and dropping it breaks them just as removing an exported method would. Because the language records it nowhere, the discipline has to come from documentation, a compile-time assertion, and release notes.
  • Can adding a method to an exported type be a breaking change too?
    It can. A new `String() string` makes the type a `fmt.Stringer`, so `%v` formatting changes for every consumer that logs it. Structural satisfaction is gained as silently as it is lost, so additive method changes on exported types deserve the same scrutiny.

saying these in an interview costs you the question

  • Says the compiler tracks which interfaces a type implements
  • Assumes go build ./... in the library would have failed
  • Expects go vet to flag a dropped interface method
  • Treats the broken interface as the consumer's problem
  • Thinks the failure appears at run time rather than at compile time