Advanced

  1. Hot reload
  2. Custom type parsers
  3. Mutators
  4. Validation
  5. Testing without global state

Hot reload

The oneenv/watch subpackage re-decodes your struct whenever a watched .env file changes. It uses native OS notifications — inotify on Linux, kqueue on BSD/macOS and ReadDirectoryChangesW on Windows — with modification-time polling as a fallback on any other platform. All standard library, so the zero-dependency guarantee still holds.

import "github.com/bakhod1r/oneenv/watch"

var cfg Config
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Blocks until ctx is cancelled. Read cfg inside onReload (or guard it with a
// mutex): Watch writes cfg concurrently with your readers.
watch.Watch(ctx, &cfg, func(err error) {
    if err != nil {
        log.Printf("reload failed: %v", err)
        return
    }
    log.Printf("config reloaded")
}, oneenv.WithEnvFiles())

Custom type parsers

Register a parser for any specific type without implementing TextUnmarshaler. It also applies to that type inside slices, maps and pointers.

import "net"

cfg, err := oneenv.Parse[Config](
    oneenv.WithTypeParser(func(s string) (net.IP, error) {
        return net.ParseIP(s), nil
    }),
)

Registering any type parser bypasses the shared schema cache for that call, since the same type could decode differently between calls. Register your parsers once and reuse the option set to keep things fast.

Mutators

A Mutator transforms every raw value after lookup and before decoding. Mutators run in registration order, each receiving the previous one’s output and a context.Context. Perfect for resolving indirections (secret managers, templating) or normalising values.

cfg, err := oneenv.ParseContext[Config](ctx,
    oneenv.WithMutator(func(ctx context.Context, key, val string) (string, error) {
        if ref, ok := strings.CutPrefix(val, "sm://"); ok {
            return secretmanager.Resolve(ctx, ref)   // your code
        }
        return val, nil
    }),
)

Returning an error from a mutator fails that field and is reported like any other field error.

Validation

oneenv stays dependency-free, so it ships no validator — but WithValidator lets you attach any one you like. It runs once, on the fully decoded struct, after a successful decode.

import "github.com/go-playground/validator/v10"

v := validator.New()
cfg, err := oneenv.Parse[Config](
    oneenv.WithValidator(func(c any) error { return v.Struct(c) }),
)

Testing without global state

The decoder never touches os.Getenv directly — everything flows through a Lookuper. In tests, pass a MapLookuper and skip os.Setenv / t.Setenv entirely, so tests stay hermetic and t.Parallel()-safe.

func TestConfig(t *testing.T) {
    t.Parallel()

    lookuper := oneenv.MapLookuper{"PORT": "3000", "HOST": "test"}

    var cfg Config
    if err := oneenv.Load(&cfg, oneenv.WithLookuper(lookuper)); err != nil {
        t.Fatal(err)
    }
    // assert on cfg…
}