In Go, what do callers lose when a package's constructor returns an exported interface instead of the concrete struct pointer?
answer
- two shapes, one call site
- count how many names you exported
- the caller can narrow, you cannot widen for them
- an assertion is the only way back
- net.Dial is the exception, sql.Open is the rule
basics
~20 sEvery method and field the interface does not list. The returned value is narrowed to the declared method set, so callers cannot reach the rest, and the package now promises two exported things — the interface and the constructor — instead of one.
solid answer
~50 sReturning `*Runner` hands the caller the whole type: every exported method, every exported field, and anything you add to it later. Returning an exported `Migrator` interface hands them exactly the methods you listed and nothing else — the extra accessor, the `Stats()` method, the field they wanted to read are all unreachable without a type assertion, and if the concrete type is unexported they cannot even assert. Meanwhile your package's promised surface grew: the interface is a second exported symbol, with its own documentation and its own method set to keep. You gained nothing in exchange, because a concrete return value still satisfies any interface that describes it. The exceptions are real but narrow — return an interface when the concrete type genuinely varies at run time, the way `net.Dial` returns a `net.Conn` — and `database/sql` shows the normal case: `sql.Open` returns a concrete `*sql.DB`.
code
go · 11 lines// One exported name; callers get every method Runner has, now and later.
func New(dir string) *Runner { return &Runner{dir: dir} }
// Two exported names; callers get exactly these three methods.
type Migrator interface {
Run(ctx context.Context) error
Pending() int
Close() error
}
func NewMigrator(dir string) Migrator { return &Runner{dir: dir} }go deeper
Know that a value returned as an interface offers only the methods that interface declares, while a returned struct pointer offers all of the type's exported methods.
Explain both costs: the caller is narrowed to the declared method set, and the package now exports an interface as well as a constructor. Name a case where an interface return is right, such as a function that can return several concrete types.
Demonstrate the asymmetry in review: callers can narrow a concrete value themselves, but cannot widen an interface you handed them, so the concrete return is the reversible choice.
Own the guidance for the codebase — when a package may publish an interface at its boundary at all — and be able to defend it against a team that reaches for interface returns reflexively.
## The two shapes ```go func New(dir string) *Runner // concrete func NewMigrator(dir string) Migrator // interface ``` Both compile. The difference is what arrives at the call site. With the concrete form, the caller holds a `*Runner`. Everything the type exports is reachable: `Run`, `Applied`, `Pending`, a `Stats()` you add next month, an exported field, a `String()` method that makes it print nicely. The caller can also store it in any interface variable they like, because a concrete value satisfies every interface whose methods it has. With the interface form, the caller holds a `Migrator` whose static type has exactly the methods you wrote in the declaration. Anything else on the concrete type is invisible. If they need one of those methods they must type-assert back — `m.(*Runner)` — and that only works if `Runner` is exported and they are willing to write code that defeats the abstraction you handed them. If the concrete type is unexported, there is no assertion to write and the method is simply gone. ## The surface cost This is the part people miss. `func New(dir string) *Runner` adds one name to your API. `func NewMigrator(dir string) Migrator` adds two: the constructor and the interface. The interface is a fully fledged exported declaration — it needs a doc comment, it appears in `go doc`, callers can name it in their own signatures and struct fields, and its method set is now something your package has published. And the interface is rarely the shape the caller wanted. You are guessing, at the moment you write the library, which subset of the type's behaviour every future caller will need. Guess narrow and callers are stuck; guess wide and the interface has ten methods and abstracts nothing. ## What you did not gain The usual argument for returning an interface is flexibility, and the argument does not survive contact with Go's structural typing. A Go type satisfies an interface by having the methods — there is no `implements` clause and no declaration linking them. So returning `*Runner` does not prevent anyone from using it through an interface: they can put it in one. Returning `Migrator` only removes options. A second common argument is documentation — "the interface shows what the type is for". A concrete type with a clear doc comment does that better, because it also shows what is actually there. ## When an interface return is right There are honest cases, and they share a property: **the concrete type genuinely varies**. - `net.Dial` returns a `net.Conn`, because a TCP connection, a Unix socket and a TLS connection are different types and the caller must not care which came back. - `net.Listen` returns a `net.Listener` for the same reason. - A package that picks an implementation from configuration — one backed by files, one by memory — has to return something that spans them. - Returning `error` is the ubiquitous example: an interface, precisely because the concrete error type varies and must be allowed to. Contrast `sql.Open`, which returns `*sql.DB`. There is exactly one `sql.DB` type; drivers vary underneath it, so the variation is handled inside, and the caller gets a concrete value with every method on it. The test is not "might someone want to substitute this?" — someone always might, and they can. The test is "does my function have more than one type it might return?" If it returns one type on every path, return that type. ## A related trap Returning an *unexported* concrete type from an exported function is legal and occasionally seen: `func Parse(s string) (parser, error)`. The caller can hold the value with `:=` and call its exported methods, but cannot write the type's name, so they cannot declare a variable, a field or a parameter of that type. It is not a way to keep the surface small — the type's exported methods are just as much a promise, and now the caller cannot even talk about it. If callers hold the value, export the type. ## The rule of thumb Give the caller the most specific thing you have. If they want less, they can narrow it themselves in one line; if you narrow it for them, they cannot widen it back. That is the whole asymmetry: a concrete return type is a small promise that leaves options open, and an interface return type is a larger promise that closes them.
- Doesn't returning a concrete type stop callers from swapping in their own implementation?No. Go interfaces are satisfied structurally, so a caller can assign your concrete value into any interface with matching methods and hold that instead. Returning the concrete type leaves that option open; returning an interface takes the others away.
- When is returning an interface actually the right call?When the function genuinely returns more than one concrete type. `net.Dial` returns a `net.Conn` because TCP, Unix and TLS connections are different types; `error` is an interface for the same reason. If every return path yields one type, return that type.
- What if the concrete type is unexported and the constructor returns an interface?Then the interface is a hard ceiling: there is no name to assert to, so callers can never reach anything outside the declared method set. That is sometimes deliberate, but be sure it is a decision and not an accident of ordering.
saying these in an interview costs you the question
- Says returning an interface is always more flexible
- Thinks a concrete return value cannot satisfy an interface
- Forgets the interface itself is a second exported symbol
- Returns an interface with ten methods to avoid choosing
- Returns an unexported type so callers cannot name it