You own a Go feature-flag SDK a dozen teams import: how do you decide between a generic API and an interface-based one?
answer
- which one can you take back
- where do the type arguments show up
- a generic type lands in their declarations
- additive helper over a plain core
- who writes the migration guide
basics
~20 sDecide by reversal cost, not elegance. Keep the core surface non-generic and offer typed convenience as additive generic helpers, so a shape you got wrong changes without a v2 module path and a dozen migrations.
solid answer
~40 sThe technical rule is easy: if the caller's own type must come back out, a type parameter earns its place; if the SDK only consumes behaviour, an interface does. The organisational rule decides it. An exported generic **type** propagates into consumers' var declarations, struct fields and signatures, so reversing it edits their code, not yours — and reversal means a v2 module path plus a migration a dozen teams must schedule. So I ship the smallest commitment that solves today's complaint: a non-generic core (`Raw(name string) (any, error)`) plus a thin package-level `Value[T any](c *Client, name string) (T, error)` over it. Callers get typed reads, the helper is removable, and no consumer declaration mentions a type parameter. I would make the core generic only on evidence, after an apidiff review.
code
go · 16 lines// Core API: no type parameters, safe to evolve.
func Raw(c *Client, name string) (any, error)
// Additive helper, implemented on top of the core.
func Value[T any](c *Client, name string) (T, error) {
var zero T
v, err := Raw(c, name)
if err != nil {
return zero, err
}
t, ok := v.(T)
if !ok {
return zero, fmt.Errorf("flag %s is %T, not %T", name, v, zero)
}
return t, nil
}go deeper
Recognise the two shapes on offer: a function whose caller names a type, and one that takes an interface. Knowing which is easier to change later is what the senior levels add.
Be able to state the technical rule cleanly: a type parameter when the caller's type must reappear in the signature, an interface when the package only consumes behaviour.
Argue from reversal cost. Rank the shapes by how much consumer code a reversal edits, and prefer the additive helper that leaves the core API free to move.
Own the whole call, including what would overturn it and who pays. Bring counted call sites, an apidiff review, a migration guide you write, and a v2 plan you have budgeted before you break anyone.
## The decision as it actually arrives You maintain a feature-flag evaluation SDK that a dozen teams import. Consumers complain about assertion boilerplate: every flag read is `v.(bool)` plus an error branch. Two proposals land in the design doc — make the API generic, or keep it interface-based and improve the docs. Both are defensible, and whichever you choose, you will not get to choose again cheaply. ## Step 1: separate the technical rule from the commitment The technical rule is short and settles perhaps a third of the argument. - **A type parameter earns its place when the caller's concrete type has to appear again** — in the result, in a second parameter, or as the element type of a collection you accept. Typed flag reads are exactly that case. - **An interface earns its place when you only consume behaviour** — an SDK that takes a `Reporter` to publish evaluation metrics wants an interface, and gains nothing from `[R Reporter](r R)`. What the rule does not tell you is whether to put the type parameter **in the core API or beside it**, and that is the whole decision. ## Step 2: rank the shapes by how hard they are to reverse This ordering is the useful artefact, because it converts *which is nicer* into *what does undoing it cost*. 1. **A non-generic function.** Reversible: you can add a generic sibling later without touching anyone. 2. **An exported generic function.** Type arguments appear at consumers' *call sites*. Reversing edits one expression per call. 3. **An exported generic type.** Type arguments appear in consumers' *declarations* — `var s *Store[Flag]`, struct fields, their own function signatures, their test helpers. Reversing edits their whole codebase's type vocabulary, and `Store[A]` and `Store[B]` are unrelated types, so anything that held them together must be redesigned too. An exported generic type is the largest commitment the type system lets you make to people you cannot call. Make it only when the type genuinely *is* a container of the caller's element type. ## Step 3: pick the shape that keeps the option open ```go // Core: no type parameters. Free to evolve, and any interface can hold it. func Raw(c *Client, name string) (any, error) // Additive helper: one call site each, deletable, invisible in consumer declarations. func Value[T any](c *Client, name string) (T, error) ``` Consumers stop writing assertions. No consumer declaration mentions a type parameter. If a year of use shows the helper is wrong — the wrong error semantics, the wrong default behaviour for a missing flag — you deprecate one function instead of renegotiating the package's type vocabulary. And because `Client` stays non-generic, teams can still hold it behind their own interfaces for testing, which a generic type would have complicated. ## Step 4: name the evidence that would change your mind A principal-level answer says what would overturn it, in numbers you can go and collect: - **Call-site counts.** Hundreds of reads across services, all with a statically known type, argues for typed reads in the core. - **The failure record.** Production incidents caused by a wrong assertion argue for moving the check into the compiler. - **The shape of consumers' code.** If several teams have already wrapped your SDK in their own typed helper, you are shipping a missing feature; standardise it rather than letting five wrappers diverge. And the constraints that argue against it: teams mid-migration on something else, no shared release train, or a consumer you cannot rebuild. ## Step 5: decide who pays If the decision does force a break, the owning team pays for the mechanical part. That means: an apidiff report between the previous release tag and the candidate attached to the design doc; a migration guide written by you, not by each consumer; a rewrite recipe where one exists — a thin deprecated wrapper marked with Go 1.26's `//go:fix inline` lets `go fix` rewrite call sites mechanically; and a v2 module path so nobody is broken by an upgrade they did not ask for. Budget the migration in the same quarter as the change, or do not ship the change. ## What a weak answer sounds like *Generics are the modern way, so the API should be generic.* That answer has no reversal cost in it, no consumer in it, and nobody who could overrule it. The judgment being tested is that an exported abstraction is a promise whose blast radius is other people's source code, and that the right first move is usually the additive one that leaves you a second choice.
- Why is an exported generic type a bigger commitment than an exported generic function?A generic function's type arguments appear at consumers' call sites. A generic type's appear in their declarations — variables, struct fields, their own signatures and test helpers — and `Store[A]` and `Store[B]` are unrelated types. Reversing the first edits expressions; reversing the second edits their type vocabulary.
- What evidence would move you to make the core API generic?Counted call sites where the type is statically known, incidents traced to wrong assertions, and several consumer teams having already written the same typed wrapper. That last one means you are shipping a missing feature, and standardising beats letting five divergent wrappers harden.
- How do you keep a forced migration from landing on twelve teams at once?Ship additively first, deprecate with a doc comment naming the replacement, and remove only at a v2 module path. The owning team writes the guide and supplies a mechanical rewrite where one exists, and the migration is budgeted in the same quarter as the change.
- Does keeping the core non-generic cost consumers anything?One internal assertion per typed read, inside your helper rather than in their code, and an error rather than a compile failure when a flag's configured type is wrong. That is a real cost, and it is the price of being able to change the helper without renegotiating every consumer's declarations.
Publishing a generic type is like changing the plug on every appliance your customers already own; publishing a generic helper is like shipping an adapter they can leave in a drawer.
saying these in an interview costs you the question
- Argues generics are the modern choice with no reversal cost considered
- Puts a type parameter on the exported client type by default
- Treats a signature change as shippable in a minor release
- Names no evidence that would change the decision
- Leaves the migration work to each consuming team