AlgoMaster Logo

Interface vs Concrete Types

Last Updated: May 22, 2026

Medium Priority
9 min read

Every function you write makes a choice about its inputs and outputs: hide them behind an interface, or expose the concrete type. This lesson is about that choice. The rule of thumb is short to state and easy to get wrong in practice, so we'll work through the decision matrix, the costs on both sides, and the cases where the obvious answer turns out to be the wrong one.

The Short Rule

Most Go style guides land on the same one-liner: accept interfaces, return concrete types. Function parameters should be the leanest interface that does the job. Return values should be concrete struct or pointer types whenever possible.

recordOrder accepts OrderRepository, an interface that asks for one method. That lets you pass any repository you want, including a fake one in a test. NewInMemoryRepo returns *InMemoryRepo, the concrete pointer. The caller can call Count, see every field, and use the type without losing information. This split, narrow input and rich output, is the spine of the rest of this lesson.

Why Parameters Should Be Interfaces

When a function takes an interface, the caller can pass any type that has the right methods. The function is decoupled from any specific implementation. That decoupling pays off in three places: testing, swapping implementations, and not breaking callers when you add internal helpers.

Consider a function that emails an order receipt. If it accepts a concrete SMTPClient, every test needs a real SMTP server. If it accepts a Mailer interface, a test can pass a fake that records the message in a slice.

The same sendReceipt works with a real SMTP-backed mailer in production and a fakeMailer in tests. Neither implementation has to know about the other. This is the main payoff for accepting an interface.

There's a second rule on top of the first one: keep the interface as small as possible. If sendReceipt only needs Send, the interface should have only Send. Don't widen it with Configure, Close, and Stats methods just because the production type has them. A one-method interface is the easiest to satisfy and the easiest to fake.

A method call through an interface goes through a function pointer table, so the compiler can't inline it. For most code, the overhead is invisible. In a tight loop running billions of times, it can show up in a profile.

Why Return Values Should Be Concrete

The other half of the rule flips for returns. When you return a concrete type, the caller gets back something fully featured: every method on the type, every exported field, and the ability to type-switch or pass it to functions that expect that concrete type. When you return an interface, you've thrown all of that away.

NewCart returns *Cart. The caller can read ItemCount, compute Total, and call any method that exists on *Cart today or that you add tomorrow. If NewCart returned an interface like this:

then a caller who wants ItemCount is stuck. They can type-assert back to *Cart, but that's an awkward dance and it undermines the point of returning an interface in the first place. Worse, if you ever change NewCart to return a different concrete type that doesn't implement *Cart's extra methods, every caller breaks.

Returning concrete also gives you headroom. You can add Clear, Remove, or Items methods to *Cart later, and existing callers keep working. They can't call the new methods, but nothing they wrote stops compiling. With an interface return, adding methods to the interface breaks every external implementation of it.

The Decision Matrix

The two rules combine into a small decision tree. Where is the type used, parameter or return? Does the caller need flexibility, or does the caller need access to everything?

The diagram captures four decisions. For parameters, use an interface when the function needs only a handful of methods; use the concrete type when the function legitimately depends on fields or many methods. For returns, return concrete by default and return an interface only when the function genuinely picks among multiple concrete types at runtime.

That last case, "factory returning one of multiple kinds", is the main reason to break the "return concrete" rule. The next two sections cover it.

When Returning an Interface Is Right

There are two real cases where a function should return an interface, not a concrete type.

Case 1: The constructor must pick from several concrete types. If a function chooses among different implementations based on its input, the only way to give back a uniform return type is an interface.

NewPaymentGateway returns either a stripeGateway or a paypalGateway depending on the string. There's no single concrete type that could be the return value, so the function returns the interface they both satisfy. This pattern is sometimes called a factory, and it's the cleanest excuse for an interface return.

Case 2: The concrete type is unexported, but the methods are public. When you want callers to use a type's methods without being able to construct it directly or see its fields, you can keep the type unexported and have the constructor return an exported interface.

Callers outside the store package can't say var r *memoryRepo because memoryRepo is unexported. They have to take what NewMemoryRepository gives them, which is the interface. Inside store, the package can still work with the concrete type directly.

This is more rule-of-thumb than rule. If you find yourself reaching for the unexported-type pattern, ask first whether exporting the type works fine. It usually does, and it's simpler.

The Hidden Cost of Interface Returns

Returning an interface has costs beyond losing methods and fields. Three are about.

Indirection. A method call on an interface value goes through a vtable lookup. The compiler can't inline the method, so loops that call methods a billion times do a billion lookups. For most application code, this is noise. For tight numeric loops, it can be measurable.

Hidden fields and extra methods. An interface return type erases everything except the methods declared on the interface. A caller who needed a field has to type-assert back to the concrete type, which is brittle. If you change the concrete type later, the assertion breaks (or panics) at runtime instead of failing at compile time.

Boxing allocations. When a small value like an int or a float64 is assigned to an interface, Go allocates on the heap to store it. This is called boxing.

Assigning p to prod stays on the stack. Assigning p to priced requires the runtime to store the type pointer and value together as an interface header, which usually means a heap allocation for the value. One allocation isn't a problem. Millions per second can be.

Storing a value in an interface variable allocates when the value doesn't fit in the interface's data word, which is most struct types. Pointers don't pay this cost because the pointer itself fits.

This is the main reason hot paths in the standard library tend to use concrete types. The bytes.Buffer methods don't take io.Writer, they operate on *bytes.Buffer directly. The interface costs add up where the work per call is tiny.

The Cost of Concrete Types

The other direction has costs too. Concrete types are tighter coupling. If recordOrder takes a *PostgresOrderRepo instead of an OrderRepository, a test can't pass a fake. Swapping to a different database backend requires changing every function signature.

Concrete types also hurt when the type carries heavy state. A *PostgresOrderRepo needs a real connection pool, real credentials, real network access. A test that uses it directly is no longer a unit test. Hiding it behind a OrderRepository interface lets tests pass a lightweight fake.

The trade-off is real, and the answer isn't "always one or the other". It's: pick the side that minimizes coupling without losing the information the caller needs.

recordOrderTight is shorter to write and lets the caller use every field on PostgresOrderRepo. It also fuses the function to that one type. recordOrderLoose accepts any repo, including a fake one, at the cost of a slightly wider declaration and the runtime indirection. In practice the loose form wins almost every time for application-level code, because the testability and the swap-out flexibility matter more than a saved nanosecond.

Leaky Abstractions

A leaky abstraction is an interface return that callers have to type-assert back to a concrete type to do real work. It combines the worst parts of both choices: the indirection of an interface, plus the fragility of relying on a specific concrete type underneath.

Standard Library Examples

The patterns in this lesson aren't theoretical; the Go standard library follows them with very few exceptions. Reading the signatures from pkg.go.dev makes the rule concrete.

FunctionReturn TypeWhy
strings.NewReader(s string)*strings.ReaderConcrete pointer. Callers get Len, Size, Seek, and other methods beyond Read.
bytes.NewBuffer(b []byte)*bytes.BufferConcrete pointer with many useful methods (Len, Bytes, String, Reset).
os.Open(name string)*os.File, errorConcrete pointer. Callers get Close, Stat, Sync, Truncate, and others.
bufio.NewReader(rd io.Reader)*bufio.ReaderConcrete pointer. Takes an interface, returns a concrete type.
http.NewRequest(...)*http.Request, errorConcrete pointer for the same reason: extra methods and fields callers need.

The consistency. Every constructor returns a concrete pointer type, even though most of these types satisfy well-known interfaces like io.Reader or io.Writer. The caller gets the rich type back and can pass it to functions that take io.Reader whenever they need to. The interface is for the parameter, not the return.

Parameters tell the opposite story. bufio.NewReader takes io.Reader, not *os.File, because it doesn't care where the bytes come from. io.Copy(dst Writer, src Reader) takes two interfaces because it works on any pair of writer and reader. json.NewDecoder(r io.Reader) takes an interface for the same reason. Small, focused interfaces flow into the function; concrete types flow out.

There are exceptions. database/sql is the famous one: sql.Open returns *sql.DB, a concrete type, but most of the package is built around the driver interfaces that backends implement. The interface lives at the plugin boundary, while application code works with the concrete *sql.DB. The pattern is consistent if you squint: interfaces for what you accept (drivers), concrete types for what you return to callers (*sql.DB).

Returning *bufio.Reader from bufio.NewReader means callers can wrap a file, buffer it, and then keep using file-specific methods on the file itself. If NewReader returned io.Reader, that flow would need either a type assertion or two separate variables. Concrete returns avoid both.

A Brief Note on Generics

Generics, added in Go 1.18, sometimes make the interface-vs-concrete choice moot. If you want a function that works on any orderable type without method dispatch, a type parameter can replace an interface and avoid the indirection cost.

The function takes a slice of any type that satisfies the constraint int | float64 and sums it. There's no interface, no boxing, no vtable lookup. The compiler generates a specialized version for each type at compile time.

Generics don't replace interfaces. Interfaces are still appropriate when you want behavior abstraction at runtime, when a function genuinely picks between concrete types, or when the set of types isn't known at the function's package. But for many cases where you'd use interface{} plus type assertions in pre-1.18 code, generics are now cleaner and faster. The full generics story has its own chapter later in the course.

Putting the Rules Together

The decision usually takes seconds in practice:

  1. Is the type going in (parameter) or coming out (return)?
  2. For parameters, what's the smallest set of methods the function actually uses?
  3. For returns, can the function return one specific concrete type, or does it pick among several?

computeTotals accepts []pricer, the smallest interface that does the job (one method, Price). Any future type with a Price method works. The function returns OrderTotals, a concrete struct, so callers can read every field directly and the compiler can verify each access.

If you ever feel torn, the bias should be toward the simpler choice: accept what you need, return what you have. The rule isn't a law of physics, but it's the right starting point for the overwhelming majority of Go functions you'll write.