skip to content

Why should an exported Go loader take an io.Reader parameter instead of *os.File?

level: middleimportance: should knowfreq 52%

answer

  1. a parameter is a demand on every caller
  2. ask for behaviour, not for a source
  3. nobody declares that they implement it
  4. narrowest thing the body actually calls

basics

~20 s

Take io.Reader, the smallest interface that covers what the function actually does. Callers can then pass a file, an HTTP response body or an in-memory reader in a test, and your package never forces a real file on disk.

solid answer

~50 s

A parameter type is a demand you make of every caller, so ask for the least you can use. If the loader only reads bytes, `io.Reader` says exactly that, and because Go interfaces are satisfied implicitly the caller passes whatever they already have — an `*os.File`, a response body, a `*strings.Reader` in a test — with no adapter and no import of your package's types. Demanding `*os.File` instead means every caller needs a real file on disk, so tests need fixtures and temporary directories for a function that never touches the filesystem. Take the narrowest interface that matches the body: `io.Reader` if you only read, `io.ReadSeeker` only if you actually seek, and `io.ReadCloser` only if you intend to close it — taking a closer implies you own the resource, and the rule is that whoever opened it closes it.

code

go · 19 lines
go
// Bad: only a real file will do, so every test needs one on disk.
func LoadRulesFromFile(f *os.File) (map[string][]string, error) {
	var rules map[string][]string
	if err := json.NewDecoder(f).Decode(&rules); err != nil {
		return nil, err
	}
	return rules, nil
}

// Good: any byte source fits — a file, an HTTP body, a test fixture.
func LoadRules(r io.Reader) (map[string][]string, error) {
	var rules map[string][]string
	if err := json.NewDecoder(r).Decode(&rules); err != nil {
		return nil, err
	}
	return rules, nil
}

// Callers: LoadRules(f), LoadRules(resp.Body), LoadRules(strings.NewReader(fixture))

go deeper

for a junior

Know that io.Reader means "anything you can read bytes from" and that files, buffers and HTTP bodies all satisfy it. Be able to say why a test is easier when the parameter is io.Reader.

for a middle

Explain implicit interface satisfaction and why it makes small parameters cheap for callers, and pick the narrowest of io.Reader, io.ReadSeeker and io.ReadCloser for a given body. Know that taking a closer implies ownership.

for a senior

Show that you weigh what a parameter demands of every caller, including future ones, and that you know when the concrete type is the honest parameter. Be ready to defend not abstracting, and to settle any dispatch-cost claim with a benchmark.

for a principal

Own the convention across a codebase: standard interfaces where they fit, consumer-side declarations where they do not, and no ceremonial one-implementation interfaces added for mocking.

## A parameter type is a requirement on callers Every parameter in an exported signature is something the caller must produce. `*os.File` requires an actual operating-system file. `io.Reader` requires only "something bytes can be read from". If the function body only ever reads bytes, the second is honest and the first is an accidental requirement that every caller pays for. ``` func LoadRules(r io.Reader) (map[string][]string, error) ``` ## What the narrower parameter buys **Callers pass what they already have.** Go interfaces are satisfied implicitly: a type does not declare that it implements `io.Reader`, it simply has the right `Read` method. So `*os.File`, `*bytes.Buffer`, `*strings.Reader`, `*bytes.Reader`, a decompressing reader, and an HTTP response body all fit without an adapter and without anyone importing your package to satisfy it. That is the property that makes small interface parameters cheap in Go in a way they are not in languages where implementing an interface is a declaration. **Tests stop needing the filesystem.** With `io.Reader`, a table test feeds `strings.NewReader(fixture)` and runs in microseconds. With `*os.File`, the same test needs a temporary directory, a written file and cleanup — all of it scaffolding for a function whose logic is parsing. **The signature documents the function.** A reader can tell from `io.Reader` that the function does not seek, does not close, does not stat, and does not care where the bytes came from. Widening to `*os.File` erases all of that. ## Take the narrowest interface that fits the body The rule is not "always take an interface", it is "ask for exactly the behaviour you use": - Only reading sequentially → `io.Reader`. - Genuinely seeking → `io.ReadSeeker`; do not take it speculatively, because it excludes streams. - Reading and closing → `io.ReadCloser`, but only if you *intend* to close it. - Needing filesystem-specific operations — file names, permissions, `Stat` — then `*os.File` or `fs.File` is the honest parameter, and the signature says so. ## Ownership: who closes it Taking `io.ReadCloser` is a statement about ownership, not a convenience. The convention is that whoever opened the resource closes it, normally with a deferred call next to the open. A function that receives a reader it did not open should not close it — the caller may want to keep reading, may be reusing a pooled connection, or may already have a deferred close of its own. If you do want to take ownership, say so in the doc comment, because a caller cannot see it from `io.ReadCloser` alone. ## Where the interface should be declared Prefer a standard library interface when one fits, because every caller already has values that satisfy it. When none fits, declare the small interface in the package that *consumes* it, listing only the methods that package calls. That keeps the dependency pointing the right way: your package names what it needs, and nobody has to import you to be usable by you. ## The costs, honestly A call through an interface is an indirect call the compiler usually cannot inline, and storing a value in an interface can force it onto the heap. For a function whose unit of work is an I/O read or a JSON document, this is noise — the syscall dwarfs it. It can matter in a hot inner loop called millions of times, and the way to settle that is a benchmark with allocation reporting, not a guess. Shaping a public API around an unmeasured dispatch cost is the wrong trade. ## The over-correction to avoid The mirror-image mistake is inventing an interface for everything: a one-implementation `RuleLoader` interface with six methods, added "for testing", is a bigger surface than the concrete type it wraps. Small, behaviour-shaped parameters like `io.Reader` earn their place; ceremonial interfaces do not.

  • Should the parameter be io.ReadCloser so the function can close the stream for the caller?
    Only if the function is taking ownership, and then the doc comment must say so. The default convention is that whoever opened the resource closes it, usually with a deferred call beside the open. Closing something you were handed can cut short a caller that still needs it or that has its own deferred close.
  • If no standard library interface fits, which package should declare the small interface?
    The one that consumes it. Declare an interface listing only the methods you call, in the package that calls them, so the dependency points from your code to the behaviour it needs. Providers then satisfy it implicitly without importing you, and each consumer can name a different, smaller subset.
  • Does taking an interface parameter cost anything at runtime?
    Calls through it are indirect and usually not inlined, and putting a value in an interface can force a heap allocation. Next to an actual read or a JSON decode that is noise. If a function is hot enough for it to matter, measure with a benchmark reporting allocations before reshaping the signature.

Asking for io.Reader is asking for a tap; asking for *os.File is asking for a specific plumbing fixture. If all you do is fill a glass, requiring the fixture just means nobody can use you at a water fountain.

saying these in an interview costs you the question

  • Takes *os.File then only calls Read on it
  • Closes a reader the function did not open
  • Invents a six-method interface for one implementation
  • Says callers must implement the interface explicitly
  • Takes io.ReadSeeker without ever seeking