What breaks for consumers when an exported Go function or type gains a type parameter in a new release?
answer
- generic functions are not values yet
- who wrote the type name in their own code
- no default type arguments in Go
- compare the two tags before tagging
- additive first, v2 only if you must
basics
~20 sAdding a type parameter is a breaking API change. Code using the function as a value stops compiling until it is instantiated, and every mention of a newly generic type must name its type arguments.
solid answer
~40 sSwapping an `any`-based signature for a generic one looks like a pure improvement until you run it past consumers. A generic function cannot be used as a value without instantiation, so `var eval = Evaluate` fails with *cannot use generic function without instantiation*. A generic **type** is worse: `Store` becomes `Store[Flag]` in every var declaration, struct field, parameter and return type downstream, and `Store[A]` and `Store[B]` are now distinct types. If the type was satisfying an interface, note that methods could not carry their own type parameters before Go 1.27 and still cannot implement an interface method. See it before your consumers do with an apidiff report between the previous release tag and the candidate, plus a CI build of real consumers, then ship additively and reserve removal for a v2 module path.
code
go · 6 lines// v1: Evaluate(c *Client, name string) (any, error)
var eval = Evaluate // taking the function as a value compiles
// v2: Evaluate[T any](c *Client, name string) (T, error)
var eval = Evaluate // error: cannot use generic function without instantiation
var eval = Evaluate[bool] // every such site must now name the typego deeper
Know that a generic function must be instantiated before it can be assigned to a variable, and that a generic type is written with its type arguments everywhere it is mentioned.
Walk through the categories that break: the function-as-value case, results the compiler cannot infer, and every downstream declaration naming a newly generic type.
Show the process, not just the breakage. Run apidiff between tags, compile a real consumer in CI, and land the change additively with a deprecation note rather than in a minor release.
Own the blast radius. Decide whether the boilerplate this removes justifies a v2 module path and a migration every consuming team must schedule, and say who writes the guide and the rewrite recipe.
## The change that looks free A feature-flag SDK exports `func Evaluate(c *Client, name string) (any, error)`. Its author, tired of consumers writing type assertions, changes it to `func Evaluate[T any](c *Client, name string) (T, error)` and tags a minor release. Every consumer that only ever wrote `Evaluate(c, "x")` and asserted the result... still breaks, and several categories of consumer break in ways the author never wrote a line of. ## What actually breaks **1. The function used as a value.** A generic function is not a value until it is instantiated. ```go var eval = Evaluate // error: cannot use generic function without instantiation var eval = Evaluate[bool] // the fix, at every such site ``` This catches every consumer that stored the function in a variable or a struct field, passed it to a higher-order helper, or registered it in a map of handlers. **2. Inference that cannot happen.** When the type parameter appears only in the result, nothing at the call site determines it, so `Evaluate(c, "dark-mode")` no longer compiles: the caller must write `Evaluate[bool](c, "dark-mode")`. That is not a niche case — it is the shape of exactly the API this change was trying to improve. **3. Every mention of a newly generic type.** If `Store` becomes `Store[T]`, then downstream code that wrote `var s *Store`, `func handle(s *Store)`, or `type deps struct { store *Store }` must all name a type argument. There is no default type argument in Go; the language has no equivalent, so nothing makes this gradual. **4. Type identity.** `Store[Flag]` and `Store[string]` are different types. Consumers who kept heterogeneous stores in one slice or map now cannot. **5. Interface satisfaction.** Methods could not declare their own type parameters before Go 1.27, and even in 1.27 a generic method cannot implement an interface method. So a change that pushes type parameters onto a method breaks any consumer who was holding the value through an interface — including their generated test doubles. **6. Removing a parameter is symmetric.** Going the other way is also breaking: sites written `F[int](x)` fail, because a non-generic function cannot be instantiated. ## How to know before they tell you - **apidiff** between the previous release tag and the release candidate. It classifies changes as compatible or incompatible and names the symbols, which turns *I think this is fine* into a list you can paste into the release notes. - **Build real consumers in CI.** One or two representative repositories, compiled against the candidate. Signature-level tooling misses idioms like the function-as-value case in a way a compiler does not. - **Read your own examples.** Anything in the package's `Example` functions or README that no longer compiles is a preview of every consumer's diff. ## How to ship it anyway Go's module rules are unambiguous: an incompatible change to an exported API in a v1 module needs a **v2 module path** (`example.com/flags/v2`), which every consumer opts into by changing their import path. That is a real cost, and it should be paid for a real gain, not for a nicer signature. The cheaper route is almost always **additive**: 1. keep `Evaluate` exactly as it is; 2. add `func Value[T any](c *Client, name string) (T, error)` alongside it, implemented on top of the old one; 3. mark the old one with a `Deprecated:` doc comment naming the replacement; 4. remove it only at the next major version, if ever. Nothing breaks, consumers migrate on their own schedule, and the migration guide is four lines instead of a project. Go 1.26's rewritten `go fix` and the `//go:fix inline` directive can even do the mechanical part of such a migration for consumers when the old function is a thin wrapper around the new one. ## The judgment underneath The author's instinct — *this removes boilerplate for everyone* — is correct about the destination and wrong about the route. Every exported signature is a promise, and a type parameter is a promise that reaches further into consumer code than an ordinary parameter does, because it appears in *their* declarations, not only at their call sites. Count the call sites, count the teams, run apidiff, then choose the additive path unless the numbers say otherwise.
- How would you ship the generic API without breaking anyone?Additively. Keep the existing function, add the generic one beside it implemented on top of the old, mark the old one with a `Deprecated:` doc comment naming the replacement, and remove it only at a v2 module path. Consumers migrate on their own schedule and the guide stays short.
- What tells you a change is incompatible before consumers find out?An apidiff report between the previous release tag and the candidate, which classifies each exported change and names the symbol. Back it with a CI job that compiles one or two real consumer repositories against the candidate, since compilation catches idioms a signature diff misses.
- Would a default type argument solve this, as in some other languages?Go has none, so no. Every use site must supply the type arguments or have them inferred, and inference cannot help when the parameter appears only in the result. That absence is exactly why the change cannot be made gradual.
- Is removing a type parameter safer than adding one?No, it is symmetric. Call sites written `F[int](x)` stop compiling because a non-generic function cannot be instantiated. Both directions are incompatible changes to an exported API and both belong behind an additive replacement or a major version.
saying these in an interview costs you the question
- Calls adding a type parameter a backward-compatible change
- Assumes inference will cover consumers who now must instantiate
- Expects a default type argument to smooth the migration
- Forgets that a generic type name appears in consumers' own declarations
- Ships it in a minor release because only the signature changed