1. Why accept fs.FS instead of calling os.* directly
When you first hear the advice “accept fs.FS”, it’s easy to take it as yet another trendy mantra from the Go world. But in practice it addresses a very specific pain: code that directly calls os.ReadFile quickly turns into code that’s hard to test properly and hard to reuse across different scenarios. And it also starts depending on the current directory, disk permissions, and the operating system’s mood.
API design is about deciding in advance what counts as your function’s input, what is its responsibility, and what you intentionally leave “outside the door”. And fs.FS is a great way to say: “I need a filesystem as a data source, but I don’t want to know which one.”
The application boundary: os outside, io/fs inside
Imagine our training app (let’s call it tasker): it stores tasks in a file and can read reference texts/templates/configuration. Before io/fs, a beginner typically does this: inside any function they write os.ReadFile("data/help.txt"), and then wonder why tests fail in weird ways on CI or on a coworker’s machine.
A better mental model is: main is the application boundary. At the boundary we can work with os, OS paths, command-line flags, and the whole “messy outside world”. But business logic and application functions should receive dependencies via parameters.
As a diagram, it’s convenient to keep this in your head:
flowchart TD
A[main / CLI] -->|"creates os.DirFS('data')"| B[app logic]
B -->|accepts fs.FS| C[read functions]
C -->|fs.ReadFile / fs.Stat| D[data]
In this diagram the most important thing is the direction: main depends on os, but application code depends only on io/fs.
3. Signatures and filenames in an API
Basic form: func X(fsys fs.FS, name string) (...)
By this point you may already have a question: “Okay, so what does the ‘right’ signature look like?”. Good news: it looks boring. And in Go, boring often means reliable.
Let’s add reading a text “banner” to tasker (for example, a greeting or a help cheat sheet), so we can show it in the CLI. We want to read a file from any FS, not only from disk.
package ui
import (
"fmt"
"io/fs"
)
func LoadBanner(fsys fs.FS, name string) (string, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", fmt.Errorf("load banner %q: %w", name, err)
}
return string(b), nil
}
Notice two things. First, we’re not calling os.ReadFile. Second, we added context to the error and wrapped it using %w, so the root cause isn’t lost.
In general, the decision “wrap or not” is part of API design: if you wrap with %w, you effectively allow the calling code to detect the root cause via errors.Is/errors.As, which makes that cause part of the contract. Sometimes this is useful, and sometimes it exposes unnecessary implementation details.
FS paths and OS paths are different “worlds”
If at this point you’re thinking, “How different can it be, a path is a path,” congratulations: you’re at the doorstep of a classic bug, the kind that takes three hours to diagnose and one package swap to fix.
An OS path lives in the filepath world. Separators depend on the platform (on Windows it’s often "\\"), there can be absolute paths, drive letters, UNC, and other joys of life.
An FS path lives in the io/fs world. It’s a relative name within a particular FS, and by convention it uses /.
A mini-table that is genuinely useful to keep nearby:
| What you’re building | What to build it with | Example |
|---|---|---|
| Disk path (OS path) | |
|
| Path inside fs.FS (FS path) | |
|
And here’s an important design nuance: if your function accepts fs.FS, then the name parameter must be an FS path, not “something that looks like a path.”
Validating the name: part of the contract when the path comes from outside
Here comes the moment where engineering paranoia looks like common sense. If name comes from a user (CLI argument, HTTP parameter, filename from configuration), you must assume it could be "../../secret.txt".
Yes, we discussed fs.ValidPath earlier, but in this lecture the key is API thinking: validation is not “somewhere later”, it’s part of the contract.
Here’s an example of “safe reading” for our tasker, which reads a user-provided note template:
package ui
import (
"fmt"
"io/fs"
)
func LoadUserTemplate(fsys fs.FS, name string) (string, error) {
if !fs.ValidPath(name) {
return "", fmt.Errorf("invalid template path: %q", name)
}
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", fmt.Errorf("read template %q: %w", name, err)
}
return string(b), nil
}
Note: fs.ValidPath checks the shape of the name, not whether the file exists. That’s perfect as an input “fuse”: we don’t try to “massage” the path, we honestly say, “no.”
4. Narrow and “optional” interfaces
Narrow interfaces: when fs.FS isn’t the only option
Many beginners first fear interfaces, and then fear them even more—because they start building “one universal interface for every possible case.” Go tends to prefer the opposite approach: the smaller the interface, the better (as long as it truly reflects your need).
fs.FS gives you only Open. But sometimes you’re writing a function that only needs to “read a file in full.” Then accepting fs.FS is fine, but not required: in io/fs there’s a narrow extension fs.ReadFileFS—an interface for filesystems that can ReadFile.
Let’s make a help-text loader in tasker. We don’t need Open/Close/Stat; we need “give me a string.”
package ui
import (
"fmt"
"io/fs"
)
func LoadHelpText(fsys fs.ReadFileFS, name string) (string, error) {
b, err := fsys.ReadFile(name)
if err != nil {
return "", fmt.Errorf("load help %q: %w", name, err)
}
return string(b), nil
}
Why is this good API design? Because the signature now honestly says: “I need an FS that can ReadFile.” Not more. Not less.
And a small philosophical note about errors fits here: errors.Is and errors.As are designed as “wrapper-aware” checks. errors.Is is a smart analog of err == target, and errors.As is a smart analog of type assertion, but across a chain of wrapping.
“Optional” capabilities: accept fs.FS, but use enhancements
Sometimes you want: “I’ll accept fs.FS, but if it has ReadFile, I’ll use it because it’s simpler.” That’s a fine idea—the key is to keep it readable.
In Go this is typically done via type assertion. We carefully check whether fsys implements an additional contract, and choose the more convenient route.
package ui
import (
"io"
"io/fs"
)
func ReadAllSmart(fsys fs.FS, name string) ([]byte, error) {
if rfs, ok := fsys.(fs.ReadFileFS); ok {
return rfs.ReadFile(name)
}
f, err := fsys.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
This technique is good because your API stays simple (fs.FS), but the implementation uses improvements when available. At the same time, you don’t require every FS implementation to “be able to do everything.”
5. What it looks like in main: os.DirFS and passing dependencies
Now it’s important to show the “boundary → inside” connection. At the boundary we work with the disk, but inside we pass an interface.
Suppose we have a directory "data" on disk, and inside it "help.txt". Then main could look like this:
package main
import (
"fmt"
"os"
"tasker/ui"
)
func main() {
fsys := os.DirFS("data")
help, err := ui.LoadBanner(fsys, "help.txt")
if err != nil {
fmt.Println("error:", err) // error: load banner "help.txt": ...
return
}
fmt.Println(help)
}
Here main knows about os.DirFS("data"), while ui.LoadBanner has no idea where the file lives: on disk, in memory, in a test MapFS, in a virtual subtree—doesn’t matter.
6. Testability is a side effect of a good API
It’s very easy to think testability is “something for later, once we get serious.” But in Go, testability often appears simply because you accepted the right interface as a parameter.
If LoadBanner accepts fs.FS, the test becomes a trivial substitution of fstest.MapFS. No mock frameworks, no magic.
package ui
import (
"testing"
"testing/fstest"
)
func TestLoadBanner(t *testing.T) {
fsys := fstest.MapFS{
"help.txt": {Data: []byte("hello")},
}
got, err := LoadBanner(fsys, "help.txt")
if err != nil {
t.Fatalf("LoadBanner: %v", err)
}
if got != "hello" {
t.Fatalf("got %q", got)
}
}
And here’s the important thing: you’re not “writing tests for the sake of tests.” You’re simply checking the function in a controlled environment, because the API makes it possible.
7. Errors and %w: when it really is part of the contract
At this point it’s tempting to give a rule like “always use %w”, but Go has an unpleasant habit: it doesn’t like absolute rules. Wrapping is not about making the error text prettier (humans will still see the message); it’s about whether programs outside can recognize the root cause.
If you wrap an error, you allow outside code to depend on that cause—and that can turn into a promise that becomes hard to change later.
For our tasker, a practical compromise is this: if the error came from a dependency that the caller “provided” (for example, an FS, Reader, Writer), then wrapping with %w is usually justified: it allows outside code to distinguish “file not found” from “permission denied” or from “I/O failure.” But if your function has internal implementation details you don’t want to expose, you can add context without giving outside code the ability to use errors.Is on internal causes.
8. Common mistakes
Mistake #1: accept dir string and do os.ReadFile(filepath.Join(dir, name)) inside.
That code looks “fine” until you try to test it without disk access. You suddenly discover you need to create temporary directories, copy files into them, manage cleanup, and depend on permissions. It’s better to accept fs.FS (or even narrower, fs.ReadFileFS) and move os.DirFS construction to the application boundary.
Mistake #2: mix up OS paths and FS paths, then “fix it with one more Join.”
This usually shows up like this: you pass a path like "data/help.txt" (or even an absolute path) into a function that takes fs.FS, and then wonder why os.DirFS("data") can’t find the file. Inside an FS, the name must be relative to that FS root—so just "help.txt" or "cfg/app.txt". If you need to build FS paths programmatically, use path.Join, not filepath.Join.
Mistake #3: don’t validate an external path and hope DirFS “will protect you.”
os.DirFS does limit the root, but validating external input is still useful: you get a clearer error, a more predictable contract, and fewer surprises when porting the code to other fs.FS implementations. If name comes from a user, fs.ValidPath should run before any attempt to open the file.
Mistake #4: wrap errors carelessly and lose the cause—or, on the contrary, expose everything.
If you write fmt.Errorf("read: %v", err), you add context but lose the ability to recognize the cause via errors.Is/errors.As. If you use %w, you preserve the cause, but you also make it observable from the outside—meaning it becomes part of the contract. This isn’t about “right/wrong”; it’s about an intentional choice: which causes you want the calling code to be able to distinguish.
Mistake #5: accept an interface that’s too broad “just in case.”
Beginners sometimes create an interface named "FileSystem" with a bunch of methods, and then never use half of them. As a result, it’s harder to write test implementations, harder to read signatures, and more likely to accidentally drag in extra dependencies. A good strategy in Go is to start with fs.FS, and if a function truly only needs full-file reads, accept fs.ReadFileFS. A narrow contract isn’t a limitation—it’s a way to make the code clearer.
GO TO FULL VERSION