JavaRush /Courses /Go SELF /embed + io/fs: fs.Sub and unified access to resources

embed + io/fs: fs.Sub and unified access to resources

Go SELF
Level 44, Lesson 2
Available

1. Why you need fs.Sub

When a project is small, strings like "assets/help.txt" look harmless. But as soon as you have more than two resources, the classic “fun adventures” begin: in one place you wrote "assets/help.txt", in another — "asset/help.txt", in a third — "./assets/help.txt", and now the program works only when the Moon is “waxing, but only on even days.” Our goal is to make the resource structure single, so the rest of the code does not have to guess prefixes.

Let’s imagine we have a learning app — a small CLI task tracker tasker. Previously it could read/write tasks to a file, and now we want to add embedded resources: help text and a “sample tasks file” template. The project structure could look like this:

tasker/
  main.go
  assets/
    help.txt
    templates/
      sample_tasks.txt

If we keep writing "assets/..." everywhere, it turns into a “magic string” smeared across the project. In Go, there’s a very practical tool for this kind of problem: fs.Sub.

2. fs.Sub: re-rooting a file system

fs.Sub is a function that takes any fs.FS and “cuts out” a subtree, making the chosen directory the new root. Sounds abstract, but the idea is very down-to-earth: as if you told your code, “pretend the folder assets/ is your /.”

Its signature looks like this:

sub, err := fs.Sub(fsys, "assets")

After that, inside sub the file "help.txt" corresponds to the original "assets/help.txt".

This solves two problems at once. First, we stop dragging the "assets/" prefix all over the codebase. Second, we limit access: if a function receives sub, it physically cannot accidentally read something “outside the resources.” This is a very nice “safety by construction,” not “safety by hope.”

3. A single entry point for resources: the assets package

We want the //go:embed directive and the path structure to live in one place. The rest of the code should work with something simple: “give me the resource fs.FS” or “read help for me.”

In real projects this is done all the time: for example, the pkgsite sources (the Go documentation website) mention that adding third-party libraries is tied to editing the //go:embed line in fs.go.

Let’s create an assets package (you could put it in internal/assets, but for simplicity we’ll just call it assets).

Embedding the resource tree

The setup is simple: we need embed.FS, because there are multiple files.

package assets

import "embed"

//go:embed assets/* assets/templates/*
var embedded embed.FS

What matters here is that //go:embed works only for package-level variables, not inside functions — we’ve already seen that. And one more important point: choose patterns carefully, otherwise you’ll embed “your entire family photo archive from 2007,” and then be surprised by the binary size (binary size is a whole separate “religious” topic in the Go world).

Returning a “rooted” FS via fs.Sub

Now we’ll do the main trick of the lecture — expose fs.FS to the outside, already “trimmed” down to assets/.

package assets

import "io/fs"

func FS() (fs.FS, error) {
	return fs.Sub(embedded, "assets")
}

Notice the style: we don’t return embed.FS, we return fs.FS. This reduces coupling: the rest of the code doesn’t need to know about embed at all; it just needs “a file system for reading.”

4. Reading resources without “magic paths”

Now we want to ensure that strings like "templates/sample_tasks.txt" don’t show up in main, and especially that we don’t end up with constructs like, “well, here we’ll add a prefix, and there we’ll remove it.” In good projects, resource paths are usually centralized.

The HelpText() function

Setup: help is needed often, and we want the call to be as brain-dead as possible (in a good way).

package assets

import (
	"fmt"
	"io/fs"
)

func HelpText() (string, error) {
	fsys, err := FS()
	if err != nil {
		return "", err
	}

	b, err := fs.ReadFile(fsys, "help.txt")
	if err != nil {
		return "", fmt.Errorf("read help.txt: %w", err)
	}
	return string(b), nil
}

Two practical points here. First, fs.ReadFile returns []byte, and we convert it to string. Second, we wrap the error so it’s clear at the top which file we tried to read.

A template from the templates/ subdirectory

Now let’s add a function that fetches the tasks file template from templates/.

package assets

import (
	"fmt"
	"io/fs"
)

func SampleTasks() ([]byte, error) {
	fsys, err := FS()
	if err != nil {
		return nil, err
	}

	b, err := fs.ReadFile(fsys, "templates/sample_tasks.txt")
	if err != nil {
		return nil, fmt.Errorf("read sample_tasks.txt: %w", err)
	}
	return b, nil
}

Note: the path "templates/sample_tasks.txt" is already relative to the “re-rooted” FS (after Sub("assets")). That is, we work as if assets/ were the root.

5. Why filepath.Join can “betray” you

Now we’ll hit a topic that trips up even energetic developers, because habits are strong. If you’ve worked with the disk a lot before, your hand naturally reaches for filepath.Join. And that makes sense: it assembles a path “correctly” for the current OS.

But FS paths inside fs.FS are not OS paths. For embed.FS and, in general, for most fs.FS implementations, a path is a string with the separator /. Even on Windows. Even if you really ask nicely.

To avoid confusion, keep this simple table in mind:

What we build What we join with Separator Example
OS disk path
filepath.Join
depends on the OS
C:\data\tasks.json
or
/home/me/tasks.json
Path inside
fs.FS
path.Join
always
/
templates/sample_tasks.txt

The idea here is: filepath is “about the OS file system,” while path is “about URL/FS-style paths.” And embed.FS lives closer to the latter.

Demonstrating the bug: “everything seems right, but it doesn’t work”

Suppose we want to read a template by file name:

package assets

import (
	"io/fs"
	"path/filepath"
)

func ReadTemplateBad(name string) ([]byte, error) {
	fsys, _ := FS()

	// filepath.Join can return "templates\sample_tasks.txt" on Windows
	p := filepath.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

On Linux/macOS you might not notice the problem for a long time (because the separator there is also /). But on Windows you’ll suddenly get “file does not exist,” even though it’s definitely embedded. And now you start suspecting the compiler of a conspiracy instead of yourself (classic).

The correct version:

package assets

import (
	"io/fs"
	"path"
)

func ReadTemplateOK(name string) ([]byte, error) {
	fsys, _ := FS()

	// path.Join always uses "/"
	p := path.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

This small detail is one of the main reasons this lecture pays so much attention to fs.Sub and “unified access”: we want path errors to be localized in one package, rather than popping up all over the project.

6. How to wire this into the tasker CLI

Now let’s build a “bridge” to the app so this isn’t abstract theory. In tasker we have a help command that should print the embedded help.txt. And we have an init command that creates a tasks file from an embedded template (on disk, using normal os.WriteFile, because embedded FS is read-only).

Printing help in main

Setup: main should be boring. The more boring main is, the lower the chance you’ll be debugging it at 3 a.m.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func main() {
	help, err := assets.HelpText()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	fmt.Print(help)
}

Notice: main has no idea where help.txt lives, what its path is, or whether we used fs.Sub. It just asks for the text.

The init command: writing a file to disk from an embedded template

Setup: embedded resources are often used as “defaults” — configs, templates, demo data. It’s convenient: the user doesn’t need to dig through the repository to find an example.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func initTasksFile(dst string) error {
	b, err := assets.SampleTasks()
	if err != nil {
		return err
	}

	return os.WriteFile(dst, b, 0644)
}

The nice part here is that we clearly separate two worlds. Reading the template goes through fs.FS (the embedded world), while writing goes through os.WriteFile (the disk world). No mess.

Diagram: the resource layer after fs.Sub

Sometimes it’s useful to visualize what’s happening with the “root.” Here’s a simple diagram:

flowchart TD
    A["embedded embed.FS
with project root"] --> B["fs.Sub(embedded, 'assets')
sub-FS"] B --> C["fs.ReadFile(fsys, 'help.txt')"] B --> D["fs.ReadFile(fsys, 'templates/sample_tasks.txt')"]

The point of the diagram is simple: after Sub all paths get shorter, and your code depends less on the directory structure in the original project.

7. Common mistakes with fs.Sub and resource access

Mistake #1: keep using paths with the prefix after fs.Sub.
It looks like this: you did fs.Sub(embedded, "assets"), and then you keep reading "assets/help.txt". As a result you’ll get “not found,” and that’s logical: the new root is already assets, so you don’t repeat it. This is usually fixed with discipline: after Sub("assets") use paths like "help.txt" and "templates/...".

Mistake #2: ignore the fs.Sub error.
fs.Sub returns (fs.FS, error) for a reason. If you misspell the directory name or the resource isn’t embedded the way you think, Sub will honestly say “I can’t.” If you ignore err, you can end up with weird errors later, elsewhere, and waste time on “why won’t help read.” The right approach is to check err immediately and fail with a clear message at the application boundary.

Mistake #3: use filepath.Join for paths inside fs.FS.
This is one of the trickiest traps, because on Unix-like systems the problem may not show up at all. But your program may end up on Windows (or just in CI) and suddenly break. For paths inside fs.FS use path.Join, and keep filepath.Join for working with OS paths.

Mistake #4: scatter path strings across the project instead of using a centralized package.
When "templates/sample_tasks.txt" appears in three different packages, any directory reshuffle turns into a mini migration. It’s much better when paths live in the assets package, and you expose functions like HelpText() and SampleTasks(). Then you can change the resource structure with almost no impact on the application logic.

Mistake #5: treat embedded resources as “secret.”
An embedded file lives inside the binary. If you put keys, tokens, or passwords there, assume you’ve published them. Even if “no one will figure it out,” that’s not a security strategy, it’s hope. Embed only what is safe to distribute.

1
Task
Go SELF, level 44, lesson 2
Locked
Portable help
Portable help
1
Task
Go SELF, level 44, lesson 2
Locked
Template by name
Template by name
1
Task
Go SELF, level 44, lesson 2
Locked
Template showcase
Template showcase
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION