skip to content

What does the io/fs.FS interface require, and why are fs.ReadFile and fs.Stat package functions?

level: middleimportance: should knowfreq 50%

answer

  1. one method, everything else optional
  2. small interfaces are easy to implement
  3. the helpers are functions, not methods
  4. a type assertion picks the fast path
  5. Open is the only thing you must write

basics

~20 s

io/fs.FS requires exactly one method: Open(name string) (fs.File, error). Helpers such as fs.ReadFile and fs.Stat are package functions because they use an optional extension interface like fs.ReadFileFS when the value implements one, and otherwise fall back to Open.

solid answer

~40 s

`fs.FS` has a single method, `Open(name string) (fs.File, error)`, which is why almost anything can implement it — `embed.FS`, the value from `os.DirFS`, a zip reader, an in-memory tree. Everything else in the package is layered on top as *optional* interfaces: `fs.ReadFileFS`, `fs.ReadDirFS`, `fs.StatFS`, `fs.GlobFS`, `fs.SubFS`. The package-level functions `fs.ReadFile`, `fs.ReadDir`, `fs.Stat`, `fs.Glob`, and `fs.Sub` each type-assert for the matching extension and call it when present — that is the fast path, since `embed.FS` can hand back its stored bytes directly — and otherwise implement the operation generically over `Open`. So a minimal implementation gets full functionality for free, while a capable one is not forced through a slow generic path. Names passed to `Open` must satisfy `fs.ValidPath`: unrooted, slash-separated, no `.` or `..` elements.

code

go · 16 lines
go
type FS interface {
	Open(name string) (File, error)
}

// fs.ReadFile, in outline:
func ReadFile(fsys fs.FS, name string) ([]byte, error) {
	if r, ok := fsys.(fs.ReadFileFS); ok {
		return r.ReadFile(name) // fast path
	}
	f, err := fsys.Open(name)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return io.ReadAll(f)
}

go deeper

for a junior

Know that fs.FS is satisfied by anything with an Open method, and that fs.ReadFile takes the filesystem as its first argument rather than being called on it.

for a middle

Explain the two-layer design: one required method plus optional extension interfaces, with the package functions type-asserting for the extension and falling back to Open when it is absent.

for a senior

Show the payoff in an API you own — a function parameter typed fs.FS runs unchanged over embedded assets, a real directory, or a re-rooted subtree, and costs nothing at the call site.

for a principal

Argue the general principle you want your codebase to follow: accept the smallest interface that does the job, and let optional interfaces recover performance, rather than widening required surfaces.

## One method The whole `io/fs` package is built on this: an `FS` is anything with `Open(name string) (File, error)`. That is the entire required surface. `File` in turn is `Read`, `Close`, and `Stat`. The reason for such a small interface is the usual Go one: the smaller the interface, the more things can implement it and the more code can be written against it. `embed.FS`, the result of `os.DirFS`, an archive reader, a filesystem backed by a network service, or a tree you build in memory for a single function all satisfy it with a handful of lines. ## The naming contract `Open` does not take an operating-system path. It takes an *fs name*: UTF-8, unrooted, slash-separated on every platform, with no element that is empty, `.`, or `..`, and no leading or trailing slash. The single exception is `.`, which names the root of the filesystem. `fs.ValidPath` reports whether a string satisfies this, and implementations are expected to return an error — conventionally a `*fs.PathError` wrapping `fs.ErrInvalid` — for anything that does not. This contract is what lets one name mean the same thing against an embedded tree and against a real directory. ## The optional interfaces Requiring only `Open` would make every implementation cheap and every *use* awkward: reading a whole file would be three lines of open, read, close, at every call site. The package solves this with two layers. First, a set of extension interfaces that an implementation *may* provide: - `fs.ReadFileFS` adds `ReadFile(name string) ([]byte, error)` - `fs.ReadDirFS` adds `ReadDir(name string) ([]fs.DirEntry, error)` - `fs.StatFS` adds `Stat(name string) (fs.FileInfo, error)` - `fs.GlobFS` adds `Glob(pattern string) ([]string, error)` - `fs.SubFS` adds `Sub(dir string) (fs.FS, error)` Second, a package-level function per operation — `fs.ReadFile`, `fs.ReadDir`, `fs.Stat`, `fs.Glob`, `fs.Sub`, plus `fs.WalkDir` — each taking the filesystem as its first argument. Each function type-asserts the argument to the matching extension interface. If the assertion succeeds it calls the method and returns; if it fails it does the work generically through `Open`. This is why the helpers are functions rather than methods: a method would have to be on the interface, which would force every implementation to provide it. As free functions they can adapt to whatever the concrete value happens to offer. ## Why the fast path matters The fallback is not merely a convenience, and the fast path is not merely a micro-optimisation. `embed.FS` already holds every file's contents as a contiguous region of the binary, so its own `ReadFile` can produce the bytes without constructing and tearing down a file handle. `os.DirFS` returns a value implementing `fs.StatFS`, `fs.ReadFileFS`, and `fs.ReadDirFS`, so it can issue one system call where the generic path would issue several. Meanwhile someone writing a fifteen-line test filesystem implements `Open` only and still gets `WalkDir`, `Glob`, and the rest working correctly. ## os.DirFS: the bridge from the operating system `os.DirFS(dir string) fs.FS` returns a filesystem rooted at a real directory. It is the adapter that lets code written against `fs.FS` run over ordinary files. Two things about it are worth stating explicitly. It is **read-only** — there is no create, write, remove, or rename anywhere in `io/fs`, by design, since a read-only abstraction is far easier to implement and to reason about. And the names you pass it are *fs names*, not operating-system paths: they are joined onto the root, so an absolute name or one containing `..` is rejected before it reaches the operating system. ## Writing to the interface, not the implementation The practical payoff is in function signatures. A function that takes `fs.FS` can be handed an `embed.FS` in the shipped binary, an `os.DirFS` during local iteration, and a re-rooted sub-view in a nested package, with no change to its body. A function that takes a directory name can be handed only a real directory, forever. Since `fs.ReadFile` and friends keep the ergonomics of the concrete case, there is rarely a reason to demand more than the interface.

  • Which names are legal to pass to Open on an fs.FS?
    Those satisfying `fs.ValidPath`: UTF-8, unrooted, slash-separated, with no empty, `.`, or `..` element and no leading or trailing slash. The root itself is named `.`. Implementations should return an error for anything else, which is why an operating-system absolute path is never a valid argument.
  • Why is there no Write or Create anywhere in io/fs?
    The abstraction is deliberately read-only. Read-only is the surface almost every consumer needs and almost every source can provide — an embedded tree, an archive, a remote store — while a mutable interface would exclude most of them and force awkward error paths. Writing stays with concrete APIs such as the `os` package.
  • What does os.DirFS give you, and what does it not?
    It gives a read-only `fs.FS` rooted at a real directory, and the returned value also implements the stat, read-file, and read-dir extensions so the helpers take their fast paths. It does not give you writing, and it does not accept operating-system paths: names are fs names, joined onto the root.

saying these in an interview costs you the question

  • Claims fs.FS requires Open, ReadDir and Stat
  • Passes an operating-system absolute path to Open
  • Thinks fs.ReadFile fails on a filesystem lacking ReadFile
  • Expects to create or write files through an fs.FS
  • Says the helpers are methods on the interface