env

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 13, 2026 License: MIT Imports: 23 Imported by: 0

README

go-rotini/env

A Go environment-variable loader that treats env vars the way encoding/json treats JSON: a struct gets populated via tags, with first-class support for live reload and pluggable sources — OS environment, in-memory maps, ordered chains, and .env files (via the separate go-rotini/dotenv package).

This package is used as the default environment-variable support package for rotini.

Features

  • One-line happy path: env.Load(&cfg) is enough for most programs
  • encoding/json-shaped API: single comma-separated env:"NAME,opt1,opt2" tag, generic LoadTo[T] convenience, a configurable Loader (the Decoder-style entry point) for advanced cases
  • Aggregated multi-error reporting with per-field attribution (*MultiError, FormatError)
  • Principled Source interface: OSEnv(), Map, Multi(...) built in; .env parsing via go-rotini/dotenv; remote secret backends in their own modules — all equal citizens
  • context.Context support: LoadContext threads the context to mutators and to UnmarshalerContext / ValidatorContext hooks
  • Atomic, lock-free reads on reload via Live[T] and sync/atomic.Pointer
  • Per-field immutable enforcement: reload candidates that change a marked field are rejected (old/new values redacted for secret fields)
  • Secret-redacting output (String(), Describe, PrintUsage, Markdown, Encode) plus a Secret[T] wrapper integrating with slog.LogValuer
  • Variable expansion on tagged values (expand tag): $VAR, ${VAR}, ${VAR-default}, ${VAR:-default}, ${VAR:?error} — references resolve against the source chain, used verbatim (no WithPrefix applied)
  • Built-in support for time.Duration, time.Time, *time.Location, url.URL, net.IP, *net.IPNet, regexp.Regexp, []byte (raw, base64, hex), plus encoding.TextUnmarshaler and encoding.BinaryUnmarshaler interop
  • Mutator chain for value rewriting (resolving secret:// indirections, fetching from a vault) and observation (logging, metrics)
  • Validate() error lifecycle hook for cross-field rules
  • Auto-generated help text (PrintUsage) and Markdown documentation (Markdown) from a tagged struct
  • Reverse direction: Encode / EncodeFile write a struct out as KEY=VALUE lines suitable for child-process env or .env.dist files
  • Zero non-stdlib runtime dependencies; no sub-packages

Installation

go get github.com/go-rotini/env

Requires Go 1.26 or later.

Quick Start

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/go-rotini/env"
)

type Config struct {
	Port    int           `env:"PORT,default=8080"        envDesc:"HTTP listen port"`
	DBURL   string        `env:"DATABASE_URL,required"    envDesc:"Primary database DSN"`
	Timeout time.Duration `env:"TIMEOUT,default=30s"      envDesc:"Request timeout"`
	Hosts   []string      `env:"HOSTS,separator=;"        envDesc:"Allowed host list"`
	APIKey  string        `env:"API_KEY,required,secret"  envDesc:"Upstream API credential"`
}

func main() {
	// One-shot load (default Loader, OSEnv source, no prefix).
	var cfg Config
	if err := env.Load(&cfg); err != nil {
		log.Fatal(env.FormatError(err))
	}
	fmt.Printf("%+v\n", cfg)

	// Generic convenience.
	cfg2, err := env.LoadTo[Config]()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", cfg2)
}

Loading from a .env file

.env parsing lives in the separate github.com/go-rotini/dotenv package (which has no dependencies). Its NewSource returns a value usable directly as a Source — dotenv doesn't import this package; the method sets just line up:

import (
	"github.com/go-rotini/env"
	"github.com/go-rotini/dotenv"
)

src, err := dotenv.NewSource(".env", dotenv.WithExpand())
if err != nil { log.Fatal(err) }

// OSEnv() listed first wins over the .env file (the standard Compose / Heroku
// precedence model). Reverse the order to make the file authoritative.
loader := env.New(env.WithSource(env.OSEnv(), src))

var cfg Config
if err := loader.Load(&cfg); err != nil {
	log.Fatal(env.FormatError(err))
}

Live reload

Live[T] wraps the loader in an atomic.Pointer[T]-backed handle. Reads are O(1) and lock-free; readers always observe a complete, validated snapshot. Reload is driven by any Source that also implements env.Watcher; sources that don't are polled at the interval set by WithPollInterval, or read once and never re-read if that option is absent.

live, err := env.NewLive[Config](env.WithSource(env.OSEnv(), changingSrc))
if err != nil { log.Fatal(err) }
defer live.Close()

// Hot-path read.
go func() {
	for {
		cfg := live.Get() // *Config — never nil after NewLive succeeds
		serve(cfg)
	}
}()

// Optional event observation.
go func() {
	for ev := range live.Events() {
		if ev.Err != nil {
			log.Printf("reload failed: %v", ev.Err)
			continue
		}
		log.Printf("reload: changed=%v", ev.Changed)
	}
}()

For automatic reload on .env (or yaml/toml/jsonc) file edits, use a live source built on github.com/go-rotini/fs's file watcher — it debounces editor save bursts and handles the "atomic save" pattern (write-temp-then-rename) by watching the parent directory and filtering by filename. The reload pipeline parses + validates a fresh candidate before swapping; a failed candidate retains the previous value and emits an error event.

Auto-generated documentation

Describe / PrintUsage / Markdown walk the same field pipeline as Load but emit documentation instead of populating a struct. Wire them into a --env-help flag or a docs-generation step:

// Print a column-aligned help table to stderr.
_ = env.PrintUsage(os.Stderr, &Config{})

// Generate a GitHub-Flavored Markdown reference.
_ = env.Markdown(os.Stdout, &Config{})

Reverse direction (Encode)

Encode writes a struct out as KEY=VALUE lines — useful for child-process env, audit snapshots, or generating an example .env.dist file:

// Default redacts secrets as ***.
_ = env.Encode(os.Stdout, &cfg)

// For child-process exec.
buf := &bytes.Buffer{}
_ = env.Encode(buf, &cfg, env.WithEncodeIncludeSecrets(true))
cmd.Env = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")

The package-level Encode / EncodeFile use the defaults (env tag, SCREAMING_SNAKE_CASE, no prefix). If you built the loader with WithTagName, WithFieldNameTransform, or WithPrefix, use the loader methods loader.Encode / loader.EncodeFile so the output round-trips through loader.Load.

For lossless .env editing (preserving comments and original quoting), use github.com/go-rotini/dotenv's Parse(*File).Set(*File).Marshal instead — Encode is a struct-to-text path and does not preserve formatting.

Multiple sources and precedence

Source chains are explicit and ordered — the first source to return ok=true wins. The cleanest mental model is "process env wins over .env file unless told otherwise" — list OSEnv() first:

loader := env.New(env.WithSource(
	env.OSEnv(),                                    // wins by default
	mustDotenv(".env.local"),                       // dev overrides   (dotenv.NewSource)
	mustDotenv(".env"),                             // baseline        (dotenv.NewSource)
))

(mustDotenv here is a thin wrapper around github.com/go-rotini/dotenv's NewSource that panics on error.) WithSourceAppend and WithSourcePrepend adjust an existing chain without replacing it.

Validation

Implement Validate() error on the config type for cross-field rules. The loader invokes it after every field has populated and reports its return value through the standard MultiError aggregation:

func (c *Config) Validate() error {
	if c.Port < 1024 && os.Geteuid() != 0 {
		return fmt.Errorf("PORT %d requires root", c.Port)
	}
	return nil
}

For validation that needs the request context, implement Validate(ctx context.Context) error instead (the ValidatorContext interface) — the loader passes the context given to LoadContext (or context.Background() for plain Load). A type implements one or the other, not both. On reload, the validator runs against the new candidate; failure retains the previous value.

Likewise, a custom field type can implement UnmarshalEnv(ctx context.Context, s string) error (UnmarshalerContext) to receive the context during decoding, instead of the plain UnmarshalEnv(s string) error (Unmarshaler).

Documentation

Full API reference is available on pkg.go.dev.

Contributing

See CONTRIBUTING.md for guidelines on how to contribute to this project.

Code of Conduct

This project follows a code of conduct to ensure a welcoming community. See CODE_OF_CONDUCT.md.

Security

To report a vulnerability, see SECURITY.md.

License

This project is licensed under the MIT License. See LICENSE for details.

Documentation

Overview

Package env loads environment variables into Go structs, with first-class support for live reload.

The mental model is encoding/json: a struct gets populated from a set of named values via tags, with extension points for custom types, sources, and lifecycle hooks. The goal is the smallest possible top-level surface for the 80% case (Load) and a principled extension story for everything else.

One-line happy path

type Config struct {
    Port     int           `env:"PORT,default=8080"`
    DBURL    string        `env:"DATABASE_URL,required"`
    Timeout  time.Duration `env:"TIMEOUT,default=30s"`
    Hosts    []string      `env:"HOSTS,separator=;"`
    APIKey   string        `env:"API_KEY,required,secret"`
}

var cfg Config
if err := env.Load(&cfg); err != nil {
    log.Fatal(env.FormatError(err))
}

Sources

All lookups go through the Source interface. The OS environment is just one source; `.env` files (via the separate github.com/go-rotini/dotenv package), in-memory maps (Map), source chains (Multi), and remote secret backends (in their own modules) are equal citizens.

loader := env.New(
    env.WithSource(env.OSEnv()),
    env.WithPrefix("APP_"),
)

Live reload

NewLive wraps the loader in a typed handle that swaps the configuration atomically when an underlying Source reports a change. Reads are O(1) and lock-free via sync/atomic.Pointer; readers always observe a complete, validated snapshot.

live, err := env.NewLive[Config](env.WithSource(env.OSEnv()))
if err != nil { log.Fatal(err) }
defer live.Close()

cfg := live.Get()  // *Config — never nil after NewLive succeeds

This package has no third-party runtime dependencies and no sub-packages. Adjacent functionality lives in its own module so consumers pay only for what they import:

  • github.com/go-rotini/dotenv — `.env` file parser, AST, lossless round-trip, Lint. Its NewSource returns a value whose Lookup method is structurally compatible with Source, so a parsed `.env` file plugs into a loader's source chain — dotenv does not import this package (or anything else; it has no dependencies).
  • github.com/go-rotini/fs — filesystem helpers including a content-agnostic file watcher (atomic-rename aware, debounced). Compose it with dotenv (or a yaml/toml/jsonc parser) into a Source that also implements Watcher to drive live reload of config files through NewLive.

Tag system

A single comma-separated tag, mirroring encoding/json:

`env:"NAME,opt1,opt2"`

Supported options: required, notEmpty, default=value, separator=char, kvSeparator=char, expand, fromFile, secret, immutable, layout=format, base64, hex, unset. (`expand` performs `$VAR` / `${VAR}` / `${VAR-default}` / `${VAR:-default}` / `${VAR:?message}` substitution on the resolved value, resolving names verbatim against the source chain; unset references expand to "". `${VAR:?msg}` for an unset VAR fails with ErrExpansionAssertion. As with os.Expand/POSIX shells, inside `${...}` a `-` (or `:-`, `:?`) starts the operator section, so a variable whose name contains `-` cannot be referenced with the `${...}` form — use a name without `-`, or `$NAME` only if no `-` follows. `WithPrefix` is NOT applied to expansion references.) Companion tags: envDesc:"..." for free-text help and envPrefix:"PFX_" on nested struct fields. See the package README for the full table.

Pitfalls

Environment variables come with platform-specific quirks. The list below covers the issues most likely to bite a new user.

OS-level:

  • Windows folds env-var names to uppercase; POSIX is case-sensitive. The OSEnv source matches the platform — for cross-platform tests, use Map with WithCaseInsensitive.
  • os.Getenv collapses unset and empty into "". The Source interface preserves the distinction via the second bool return; OSEnv uses os.LookupEnv to honor it. The `required` tag option is satisfied by a set-but-empty value; `notEmpty` requires both set and non-empty.
  • os.Setenv / os.Unsetenv are not thread-safe under cgo (golang/go #63567). This package never calls them from library code; the `unset` tag option is the single opt-in exception, serialized within a Load call. Disable globally via WithoutUnset in test environments.
  • os.Environ returns degenerate entries on Windows (empty keys, leading-`=` per-drive variables). OSEnv filters them; you don't see them via Source.Lookup.

Live reload:

  • Reload is driven by any Source that also implements Watcher. Sources that don't are polled at the interval set by WithPollInterval, or read once and never re-read if that option is absent. For file-backed config (`.env` / yaml / toml / jsonc), wrap the parser in a Source + Watcher built on github.com/go-rotini/fs's file watcher, which handles the editor "atomic save" pattern (write-temp-then-rename) by watching the parent directory and filtering by filename — the only correct strategy, since the rewritten file is a new inode.
  • The reload pipeline parses + validates into a fresh candidate before swapping; failures retain the previous value and emit an error event. The struct returned by Live.Get is therefore always a successfully-validated snapshot.
  • Treat the struct returned by Live.Get as IMMUTABLE. The whole point of the sync/atomic.Pointer swap is that readers can rely on a stable view; mutating it through the returned pointer is a race.
  • Fields tagged `immutable` reject reload candidates that would change them. Initial load is exempt; subsequent reloads compare against the last successful snapshot. For a field that is also `expand`-tagged the comparison uses the post-expansion value, so a change in a referenced variable counts even when the field's own `${...}` template is unchanged.

Concurrency:

  • Loader is safe for concurrent use after construction.
  • Loader.Watch mutates v in place from the worker goroutine — readers must NOT touch v concurrently. For lock-free reads use NewLive instead.
  • Source implementations are required to make Lookup safe for concurrent use; built-in sources do.

Test isolation:

  • Global os.Environ is shared state; tests that set it are NOT parallel-safe even via testing.T.SetEnv. For parallelizable tests, prefer Map over OSEnv as the loader's source.

Encoding (reverse direction):

  • Encode is a tooling feature, not a primary API. Secret-tagged fields redact to "***" by default; WithEncodeIncludeSecrets opts in. Encode + Load is byte-stable for canonical inputs but is not a round-trip for `.env` FILES (comments and original quoting are lost). For lossless `.env` editing, use github.com/go-rotini/dotenv's Parse → (*File).Set → (*File).Marshal.

Architecture:

  • This package has zero third-party runtime dependencies and no sub-packages. `.env` parsing is in github.com/go-rotini/dotenv (also dependency-free); file watching is in github.com/go-rotini/fs. Consumers who only need OSEnv + Map pay no transitive cost. Verified by `go list -deps`.

Index

Constants

View Source
const DefaultReloadDebounce = 75 * time.Millisecond

DefaultReloadDebounce is the quiet period Loader.Watch and Live wait after the last Source-change event before reloading. 75ms covers an editor save's burst of fsnotify events without feeling laggy in interactive use.

Variables

View Source
var (
	// ErrMissingRequired matches any [*MissingRequiredError].
	ErrMissingRequired = &MissingRequiredError{}

	// ErrEmptyValue matches any [*EmptyValueError].
	ErrEmptyValue = &EmptyValueError{}

	// ErrParse matches any [*ParseError].
	ErrParse = &ParseError{}

	// ErrValidation matches any [*ValidationError].
	ErrValidation = &ValidationError{}

	// ErrImmutableChanged matches any [*ImmutableChangedError].
	ErrImmutableChanged = &ImmutableChangedError{}

	// ErrCycle matches any [*CycleError].
	ErrCycle = &CycleError{}

	// ErrMultiError matches any [*MultiError].
	ErrMultiError = &MultiError{}

	// ErrNilPointer is returned when [Loader.Load] is called with a nil
	// pointer or a non-pointer.
	ErrNilPointer = errors.New("env: non-nil pointer to struct required")

	// ErrNotStruct is returned when the target type is not a struct.
	ErrNotStruct = errors.New("env: target must be a struct or pointer to struct")

	// ErrUnknownTagOption is returned when a struct tag contains an
	// unrecognized option.
	ErrUnknownTagOption = errors.New("env: unknown tag option")

	// ErrConflictingFields is returned when two fields in the same
	// struct resolve to the same env-var key after prefix accumulation.
	ErrConflictingFields = errors.New("env: conflicting field names")

	// ErrUnsupportedType is returned when a struct field's target type
	// cannot be decoded from a string by any built-in or interface
	// mechanism (chan, func, complex, bare any, struct without any
	// unmarshaler).
	ErrUnsupportedType = errors.New("env: unsupported target type")

	// ErrExpansionAssertion is returned (wrapped, with field context)
	// when a `${VAR:?message}` reference in an `expand`-tagged value
	// names a variable that is not set.
	ErrExpansionAssertion = errors.New("env: ${VAR:?...} assertion failed")

	// ErrFromFileRead is returned when a fromFile-tagged field
	// references a path that cannot be read.
	ErrFromFileRead = errors.New("env: fromFile target is not readable")
)

Sentinel errors. The struct-pointer sentinels match by type via each error's Is method; the errors.New sentinels match by value identity.

View Source
var ErrNilContext = errors.New("env: nil context")

ErrNilContext is returned when a watch / load call receives a nil context.

View Source
var ErrWatcherClosed = errors.New("env: watcher closed")

ErrWatcherClosed is returned by Live.Reload when the worker goroutine has already exited.

Functions

func Encode

func Encode(w io.Writer, v any, opts ...EncodeOption) error

Encode writes v to w as a `.env`-format byte stream. Each exported, env-tagged field becomes one line: `KEY=VALUE`. Nil pointer fields and nil slice/map fields are omitted.

v may be a struct value or a non-nil pointer to one. Output is sourceable by Bash (with WithEncodeExport) and re-parseable by the github.com/go-rotini/dotenv parser.

Encode uses the default field-name mapping (SCREAMING_SNAKE_CASE), the default tag name ("env"), and no key prefix. For a loader built with WithTagName, WithFieldNameTransform, or WithPrefix, use Loader.Encode so the output round-trips through that loader.

func EncodeFile

func EncodeFile(path string, v any, opts ...EncodeOption) error

EncodeFile encodes v to path with mode 0600 (owner-only — these files often hold secrets). Existing files are truncated.

func FormatError

func FormatError(err error) string

FormatError renders err into a human-readable string. For *MultiError, each wrapped error is rendered on its own line. Returns "" when err is nil. See FormatErrorColor for an ANSI-colored variant.

func FormatErrorColor

func FormatErrorColor(err error) string

FormatErrorColor is like FormatError but wraps each message in ANSI bold-red. Use it only when the destination is a terminal.

func Load

func Load(v any) error

Load decodes environment variables into v using the default Loader (OS env source, no prefix, "env" tag name). v must be a non-nil pointer to a struct. Per-field errors are aggregated into a *MultiError; field-collection errors (conflicting names, unknown tag options) are returned directly.

func LoadTo

func LoadTo[T any](opts ...Option) (T, error)

LoadTo is the generic form of Load. T must be a struct type. opts are applied to a fresh Loader; with no opts the default loader is used.

func Markdown

func Markdown(w io.Writer, v any, opts ...Option) error

Markdown writes a GitHub-Flavored Markdown table of v's loadable fields to w. Suitable for embedding in `docs/CONFIG.md`.

func MustLoad

func MustLoad(v any)

MustLoad is like Load but panics on error.

func MustLoadTo

func MustLoadTo[T any](opts ...Option) T

MustLoadTo is like LoadTo but panics on error.

func PrintUsage

func PrintUsage(w io.Writer, v any, opts ...Option) error

PrintUsage writes a column-aligned table of v's loadable fields to w. Suitable for `myapp --env-help`-style output.

Types

type Change

type Change struct {
	Keys []string
}

Change describes a Source-level change. Keys is the set of variable names that were added, modified, or removed since the last event. An empty Keys slice means "the source signaled a change but did not identify which keys"; the loader treats this as "everything may have changed" and re-reads all fields on the next reload.

type CycleError

type CycleError struct {
	// Chain is the reference chain leading to the cycle, e.g.
	// ["DSN", "HOST", "DSN"]. It may hold a single element when the
	// non-termination is a self-referential default rather than a
	// genuine cross-variable cycle.
	Chain []string
}

CycleError is reported when variable expansion (the `expand` tag option) does not terminate — a reference cycle (A → B → A) or excessive nesting in `${VAR:-default}` chains.

func (*CycleError) Error

func (e *CycleError) Error() string

func (*CycleError) Is

func (*CycleError) Is(target error) bool

type EmptyValueError

type EmptyValueError struct {
	Field string
	Key   string
}

EmptyValueError is reported for a field marked notEmpty whose key is set to the empty string.

func (*EmptyValueError) Error

func (e *EmptyValueError) Error() string

func (*EmptyValueError) Is

func (*EmptyValueError) Is(target error) bool

type EncodeOption

type EncodeOption func(*encoderOptions)

EncodeOption configures Encode and EncodeFile.

func WithCustomMarshaler

func WithCustomMarshaler[T any](fn func(T) (string, error)) EncodeOption

WithCustomMarshaler registers a custom marshaler for type T. Runs at the top of the encoder priority chain, ahead of Marshaler, encoding.TextMarshaler, encoding.BinaryMarshaler, and the built-in registry.

func WithEncodeComments

func WithEncodeComments(c map[string]string) EncodeOption

WithEncodeComments registers per-key comment overrides. Map keys are resolved env-var names; values are emitted as `# <text>` lines preceding the assignment, replacing any envDesc text.

func WithEncodeExport

func WithEncodeExport(b bool) EncodeOption

WithEncodeExport prepends `export ` to each emitted line, producing output sourceable by Bash.

func WithEncodeIncludeSecrets

func WithEncodeIncludeSecrets(b bool) EncodeOption

WithEncodeIncludeSecrets emits the underlying value of secret-tagged fields instead of the `***` marker. Use only when the destination is known to be safe (e.g., piping to a child process via inherited env, not when writing to a public file).

func WithEncodeQuoteAll

func WithEncodeQuoteAll(b bool) EncodeOption

WithEncodeQuoteAll forces every value to be double-quoted. The default emits values bare when they contain no characters that require quoting.

func WithEncodeSort

func WithEncodeSort(b bool) EncodeOption

WithEncodeSort emits keys in lexicographic order; the default is declaration order.

type Event

type Event struct {
	// Changed lists the env-var names whose resolved values differ
	// from the snapshot taken before this reload, sorted and
	// deduplicated. Empty on a failed or no-op reload.
	Changed []string

	// Err is non-nil on failed reload; the previously-loaded value is
	// retained.
	Err error

	// Time is the wall-clock time the reload completed.
	Time time.Time
}

Event is delivered on the channel returned by Loader.Watch or by Live.Events. One Event corresponds to one reload attempt — successful or failed.

type FieldDoc

type FieldDoc struct {
	// Name is the resolved env-var name, with [WithPrefix] applied.
	Name string

	// GoType is the destination type in source notation, e.g.
	// "int", "time.Duration", "[]string", "*url.URL".
	GoType string

	// Description is the free-text help string from the envDesc tag.
	Description string

	// Default is the literal default value from the default= tag option.
	// Empty when no default is set.
	Default string

	// Required is true when the field carries the required tag option,
	// or implicitly required under [WithRequireAll].
	Required bool

	// NotEmpty is true when the field carries the notEmpty tag option.
	NotEmpty bool

	// Secret is true when the field carries the secret tag option.
	Secret bool

	// Immutable is true when the field carries the immutable tag option.
	Immutable bool

	// Separator is the slice/array separator from the separator= tag
	// option, or empty when defaulting to ",".
	Separator string
}

FieldDoc describes one loadable field. Returned by Describe and consumed by PrintUsage / Markdown; users may also walk the slice programmatically (for instance to render their own help layout).

func Describe

func Describe(v any, opts ...Option) []FieldDoc

Describe returns a FieldDoc for each loadable leaf of v. Returns nil when v is not a struct (or pointer to one) or when the struct's tags fail to validate; use PrintUsage / Markdown for the error-returning variants. opts are applied to a fresh Loader.

type ImmutableChangedError

type ImmutableChangedError struct {
	Field string
	Key   string
	Old   string
	New   string
}

ImmutableChangedError is reported when a reload candidate would have changed a field tagged immutable. For secret-tagged fields, Old and New are redacted (run through the loader's secret redactor).

func (*ImmutableChangedError) Error

func (e *ImmutableChangedError) Error() string

func (*ImmutableChangedError) Is

func (*ImmutableChangedError) Is(target error) bool

type Live

type Live[T any] struct {
	// contains filtered or unexported fields
}

Live wraps a Loader in an atomic-pointer-swapping live-reload container. After NewLive returns, Live.Get is lock-free and torn-read-free: every reload installs a fresh `*T` via atomic.Pointer.Store, so readers always observe a complete, validated snapshot.

Treat the struct returned by Get as IMMUTABLE — mutating it would race with future readers, defeating the atomic-swap design. The intended pattern is "read fields off Get() in a hot path"; for any state the reader itself mutates, copy out first.

func NewLive

func NewLive[T any](opts ...Option) (*Live[T], error)

NewLive constructs a Loader with the given options and performs an initial Load. Returns nil and the error on first-load failure. On success the returned Live runs a worker goroutine that reloads in response to source-change events and Live.Reload calls.

func NewLiveWithLoader

func NewLiveWithLoader[T any](l *Loader) (*Live[T], error)

NewLiveWithLoader is like NewLive but uses an existing *Loader.

func (*Live[T]) Close

func (l *Live[T]) Close() error

Close stops the worker, cancels every Watcher subscription, and closes the events channel. Idempotent. Live.Get continues to return the last-loaded value after Close.

func (*Live[T]) Events

func (l *Live[T]) Events() <-chan Event

Events returns the channel of Event values from the worker — one per reload attempt (success or failure). The channel is closed by Live.Close.

The channel is buffered (capacity 16); a slow consumer drops surplus events. Get() still returns the freshest value, so dropped events never hide a successful reload from a hot-path reader.

func (*Live[T]) Get

func (l *Live[T]) Get() *T

Get returns the most recently loaded value. Lock-free, torn-read- free, never nil after NewLive succeeds. Callers must treat the pointed-to struct as immutable; the worker may install a new pointer at any time, but every previously-handed-out pointer continues to reference a frozen value.

func (*Live[T]) Reload

func (l *Live[T]) Reload() error

Reload triggers a synchronous reload through the worker, bypassing the debounce timer. Returns the reload error (validation, immutable, decode) or nil on success.

type Loader

type Loader struct {
	// contains filtered or unexported fields
}

Loader is a configured environment-variable loader. Construct with New; safe for concurrent use after construction.

func New

func New(opts ...Option) *Loader

New returns a Loader with opts applied to the defaults: source chain = [OSEnv], no prefix, tag name "env", SCREAMING_SNAKE_CASE field-name transform, no logger, optional-by-default field policy.

func (*Loader) Describe

func (l *Loader) Describe(v any) []FieldDoc

Describe is the Loader-method form of Describe. FieldDoc.Name includes the loader's WithPrefix.

func (*Loader) Encode

func (l *Loader) Encode(w io.Writer, v any, opts ...EncodeOption) error

Encode is the loader-aware counterpart of Encode: it honors this loader's WithTagName, WithFieldNameTransform, and WithPrefix, so the result round-trips through this loader's Loader.Load. Source-, mutator-, validator-, and WithoutUnset-related options play no part in encoding.

func (*Loader) EncodeFile

func (l *Loader) EncodeFile(path string, v any, opts ...EncodeOption) error

EncodeFile is the loader-aware counterpart of EncodeFile; see Loader.Encode.

func (*Loader) Load

func (l *Loader) Load(v any) error

Load decodes the configured sources into v. v must be a non-nil pointer to a struct. Per-field errors aggregate into a *MultiError after every field is attempted; field-collection errors (conflicting names, unknown tag options) are returned directly.

func (*Loader) LoadContext

func (l *Loader) LoadContext(ctx context.Context, v any) error

LoadContext is like Loader.Load but accepts a context. ctx is forwarded to the Mutator chain, to fields whose type implements UnmarshalerContext, and to a ValidatorContext hook on the target struct (if it implements one). ctx must not be nil.

func (*Loader) Markdown

func (l *Loader) Markdown(w io.Writer, v any) error

Markdown is the Loader-method form of Markdown.

func (*Loader) PrintUsage

func (l *Loader) PrintUsage(w io.Writer, v any) error

PrintUsage is the Loader-method form of PrintUsage. Returns an error when v is not a struct, struct tags fail to validate, or writing fails.

func (*Loader) Snapshot

func (l *Loader) Snapshot(v any) (map[string]string, error)

Snapshot returns, for every recognized field of v, the raw value the loader would start from: the source-chain value if present, otherwise the field's default= value; fields with neither are omitted. Keys carry WithPrefix. Mutators, the `expand` tag, `fromFile`, and decoding are NOT applied — Snapshot is the "what the sources say" view, useful for tests and debugging (feed it back to Map).

func (*Loader) Watch

func (l *Loader) Watch(ctx context.Context, v any) (<-chan Event, error)

Watch starts live reload. v must be a non-nil pointer to a struct (same constraints as Loader.Load). Watch performs an initial Load synchronously; on failure it returns a nil channel and the error.

On every change pushed by a Watcher source — or on every poll tick when WithPollInterval is set — Watch builds a fresh candidate of v's type, runs the full load pipeline, compares the candidate's immutable-tagged values against the previous reload's snapshot, and on success overwrites *v. One Event is delivered per reload attempt; failures (validation, immutable, decode) deliver an Event with Err set and leave *v unmodified.

The channel is closed when ctx is canceled.

v must NOT be read concurrently with the worker's writes. For lock-free, torn-read-free reads use NewLive instead.

type MapOption

type MapOption func(*mapOptions)

MapOption configures a Map source.

func WithCaseInsensitive

func WithCaseInsensitive() MapOption

WithCaseInsensitive returns a MapOption that makes the Map source fold ASCII keys to uppercase for comparison, mirroring Windows env-var semantics. Useful in cross-platform tests that need to exercise the Windows case-folding behavior on a POSIX host.

Folding is ASCII-only; non-ASCII bytes pass through unchanged.

type Marshaler

type Marshaler interface {
	MarshalEnv() (string, error)
}

Marshaler is implemented by types that render themselves as a string for the reverse Encode path.

type MissingRequiredError

type MissingRequiredError struct {
	Field string
	Key   string
}

MissingRequiredError is reported for a field marked required (or notEmpty) whose key is absent from every Source and which has no default.

func (*MissingRequiredError) Error

func (e *MissingRequiredError) Error() string

func (*MissingRequiredError) Is

func (*MissingRequiredError) Is(target error) bool

type MultiError

type MultiError struct {
	Errors []error
}

MultiError aggregates per-field errors from a single Load call. Implements the Go 1.20+ Unwrap() []error convention so errors.Is and errors.As walk every branch.

func (*MultiError) Append

func (m *MultiError) Append(err error)

Append appends err to the aggregator. Nil errors are ignored.

func (*MultiError) Error

func (m *MultiError) Error() string

func (*MultiError) Is

func (*MultiError) Is(target error) bool

func (*MultiError) Unwrap

func (m *MultiError) Unwrap() []error

Unwrap returns the aggregated errors for errors.Is / errors.As walking.

type Mutator

type Mutator interface {
	// MutateEnv inspects or rewrites the value the loader is about to
	// decode.
	//
	//   originalValue : the source's view (no defaults applied);
	//                   "" when sourced is false.
	//   currentValue  : the value after default application and any
	//                   earlier mutators in the chain.
	//   sourced       : false when no Source returned ok=true.
	//
	// stop=true halts the chain immediately and feeds newValue to the
	// decoder. A non-nil err terminates the per-field pipeline.
	MutateEnv(
		ctx context.Context,
		originalKey, resolvedKey, originalValue, currentValue string,
		sourced bool,
	) (newValue string, stop bool, err error)
}

Mutator observes or rewrites resolved values before they reach the decoder. The chain runs once per field per Load, after source lookup and default application, before decoding. Common uses: resolving indirections (e.g., "secret://path" → vault fetch), synthesizing values for unset fields, observation for logging or metrics.

Mutators do NOT run for fields tagged env:"-". They DO run for unset fields with no default — a mutator may synthesize a value.

type MutatorFunc

type MutatorFunc func(
	ctx context.Context,
	originalKey, resolvedKey, originalValue, currentValue string,
	sourced bool,
) (string, bool, error)

MutatorFunc adapts a function into a Mutator.

func (MutatorFunc) MutateEnv

func (f MutatorFunc) MutateEnv(
	ctx context.Context,
	originalKey, resolvedKey, originalValue, currentValue string,
	sourced bool,
) (string, bool, error)

MutateEnv implements Mutator.

type Option

type Option func(*loaderOptions)

Option configures a Loader. Apply via New or LoadTo.

func WithCustomDecoder

func WithCustomDecoder[T any](fn func(s string, dst *T) error) Option

WithCustomDecoder registers a custom decoder for type T. Runs at the top of the decoder priority chain, ahead of Unmarshaler, encoding.TextUnmarshaler, encoding.BinaryUnmarshaler, and the built-in registry. fn receives the resolved string and a pointer to the destination of type T.

func WithFieldNameTransform

func WithFieldNameTransform(fn func(goFieldName string) string) Option

WithFieldNameTransform overrides the default SCREAMING_SNAKE_CASE transform for fields without an explicit tag name. fn receives the Go field name and returns the env-var name.

Loaders with a custom transform bypass the per-type field-info cache and re-walk the struct on every Load.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger directs non-fatal loader diagnostics to l. Default is a no-op logger.

func WithMutator

func WithMutator(m ...Mutator) Option

WithMutator registers one or more [Mutator]s in chain order. May be called multiple times to extend the chain. The chain runs once per field, after source lookup and default application, before decoding.

func WithPollInterval

func WithPollInterval(d time.Duration) Option

WithPollInterval configures how often Loader.Watch and Live re-read sources that do not implement Watcher. Default 0 (no polling); a positive duration enables poll-based reload for non-Watcher sources. Watcher sources push events regardless. Negative values are clamped to 0.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix prepends prefix to every resolved env-var name. Applied before any tag-supplied envPrefix on nested struct fields.

func WithReloadDebounce

func WithReloadDebounce(d time.Duration) Option

WithReloadDebounce coalesces burst events from Watcher sources: the worker waits for d of quiet before re-running the load pipeline; events arriving within d collapse into one reload. Default DefaultReloadDebounce. Negative values are clamped to 0 (no debouncing — intended for tests only).

func WithRequireAll

func WithRequireAll() Option

WithRequireAll inverts the default optional-by-default policy: every untagged field becomes implicitly required unless it has a default= tag option.

func WithSecretRedactor

func WithSecretRedactor(fn func(value string) string) Option

WithSecretRedactor overrides the redaction function applied to secret-tagged field values surfacing in error messages and Describe / PrintUsage / Markdown output. The default returns "<redacted: N bytes>" (or "<redacted>" for empty). Pass nil to restore the default. The encoder's separate "***" marker is unaffected.

func WithSource

func WithSource(srcs ...Source) Option

WithSource replaces the loader's source chain. The default chain is OSEnv alone; to layer additional sources, list them explicitly:

env.WithSource(env.OSEnv(), dotenvSrc)

Sources are consulted in order; the first to return ok=true wins. An empty argument list produces a loader whose chain has no entries.

func WithSourceAppend

func WithSourceAppend(src Source) Option

WithSourceAppend adds src to the end of the existing source chain (lowest precedence — consulted only when no earlier source matches).

func WithSourcePrepend

func WithSourcePrepend(src Source) Option

WithSourcePrepend adds src to the front of the existing source chain (highest precedence).

func WithTagName

func WithTagName(name string) Option

WithTagName overrides the struct tag name the loader recognizes (default "env"). Set to "envconfig" for caarlos0/envconfig-style migrations. Does not affect the envDesc / envPrefix companion tags.

Loaders with a non-default tag name bypass the per-type field-info cache and re-walk the struct on every Load.

func WithoutUnset

func WithoutUnset() Option

WithoutUnset disables the per-field `unset` tag option globally, keeping the process env inspectable after Load. Useful in tests.

type ParseError

type ParseError struct {
	Field string
	Key   string
	Value string
	Type  string
	Cause error
}

ParseError is reported when a string value cannot be converted to a field's target Go type. Field, Key, Value, and Type may be empty when the error originates inside the decoder before the loader has wrapped it with field context.

func (*ParseError) Error

func (e *ParseError) Error() string

func (*ParseError) Is

func (*ParseError) Is(target error) bool

func (*ParseError) Unwrap

func (e *ParseError) Unwrap() error

type Secret

type Secret[T any] struct {
	// contains filtered or unexported fields
}

Secret is a generic wrapper that hides T's value in textual output and integrates with the standard observability surfaces:

  • fmt.Stringer, fmt.GoStringer: return the redaction marker, so fmt.Println / %v / %#v emit the marker, never the value.
  • slog.LogValuer: emits the marker, so slog attributes redact automatically.
  • Marshaler: returns the underlying T for the reverse Encode path. The encoder's secret-tag check normally redacts to "***" first; MarshalEnv runs only under WithEncodeIncludeSecrets.
  • Unmarshaler: decodes via the standard decoder pipeline; works for any T the decoder accepts.

The wrapper and the secret tag option are interchangeable for redaction purposes:

type Config struct {
    Token env.Secret[string] `env:"TOKEN,required"`
    Other string             `env:"OTHER,required,secret"`
}

func NewSecret

func NewSecret[T any](v T) Secret[T]

NewSecret returns a Secret[T] holding v.

func (Secret[T]) GoString

func (s Secret[T]) GoString() string

GoString implements fmt.GoStringer and returns the redaction marker, so %#v formatting also redacts.

func (Secret[T]) LogValue

func (s Secret[T]) LogValue() slog.Value

LogValue implements slog.LogValuer and returns the redaction marker.

func (Secret[T]) MarshalEnv

func (s Secret[T]) MarshalEnv() (string, error)

MarshalEnv implements Marshaler for the reverse Encode path. Renders the inner T via fmt.Sprint — round-trip stable for primitives; other types may need a custom marshaler.

func (Secret[T]) MarshalJSON

func (s Secret[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements encoding/json.Marshaler and returns the redaction marker as a JSON string.

func (Secret[T]) String

func (s Secret[T]) String() string

String implements fmt.Stringer and returns the redaction marker.

func (*Secret[T]) UnmarshalEnv

func (s *Secret[T]) UnmarshalEnv(text string) error

UnmarshalEnv implements Unmarshaler by delegating to the package's decoder against the inner T. If T relies on UnmarshalerContext, it receives context.Background() (the wrapper has no context to thread).

func (Secret[T]) Value

func (s Secret[T]) Value() T

Value returns the underlying T. Use sparingly: every call expands the risk surface for accidental logging.

type Source

type Source interface {
	// Lookup returns the value associated with key and whether the key is
	// set. ok=true with an empty value distinguishes "set but empty" from
	// "unset"; this distinction is preserved end-to-end through the loader.
	Lookup(key string) (value string, ok bool)
}

Source provides string-keyed value lookups. It is the primary contract by which the loader resolves env-var values: the OS environment is just one source, and `.env` files, in-memory maps, and remote secret backends all implement the same interface.

Implementations MUST make Lookup safe for concurrent use. All built-in sources satisfy this; third-party sources should as well.

func Map

func Map(m map[string]string, opts ...MapOption) Source

Map returns a Source backed by a copy of m. Mutating m after construction does not affect the Source. The returned Source is safe for concurrent Lookup.

Pass WithCaseInsensitive to enable Windows-style ASCII case-insensitive key matching.

func Multi

func Multi(srcs ...Source) Source

Multi returns a Source that consults srcs in order; the first source returning ok=true wins. An empty src list returns a Source whose Lookup always returns ("", false). A single-source argument is returned as-is without an additional indirection.

func OSEnv

func OSEnv() Source

OSEnv returns a Source backed by os.LookupEnv. The source is stateless and reflects the live process environment on every Lookup.

On Windows, os.LookupEnv folds variable names to uppercase for comparison; on POSIX systems names are case-sensitive.

Lookup rejects degenerate keys (empty, leading "=", containing "=" or NUL) without consulting the OS, so callers passing such keys always observe ok=false instead of triggering platform-specific edge cases like Windows "=C:" per-drive hidden variables.

type Unmarshaler

type Unmarshaler interface {
	UnmarshalEnv(s string) error
}

Unmarshaler is implemented by types that parse themselves from a string. The decoder calls UnmarshalEnv on the field's address, so types should declare the method with a pointer receiver.

type UnmarshalerContext

type UnmarshalerContext interface {
	UnmarshalEnv(ctx context.Context, s string) error
}

UnmarshalerContext is like Unmarshaler but receives the context passed to Loader.LoadContext (or context.Background() for Loader.Load / the package-level Load). The loader checks a field's address for this interface and, failing that, for Unmarshaler — a type satisfies one or the other, never both (Go method names cannot be overloaded). It is consulted only at the top level of a field, not for slice elements or map values, which the (context-free) value decoder handles.

type ValidationError

type ValidationError struct {
	Cause error
}

ValidationError wraps an error returned from a target struct's Validator.Validate (or ValidatorContext.Validate) method.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) Is

func (*ValidationError) Is(target error) bool

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() error

type Validator

type Validator interface {
	Validate() error
}

Validator is implemented by struct types that need cross-field validation after loading. The loader detects it via interface assertion on the target's pointer receiver and calls Validate once, after every field has been populated. If per-field errors exist Validate is skipped, since the struct is in a partial state. A non-nil return is wrapped in a *ValidationError and added to the *MultiError.

type Config struct {
    DBHost string `env:"DB_HOST"`
    DBUser string `env:"DB_USER"`
}

func (c *Config) Validate() error {
    if c.DBHost != "" && c.DBUser == "" {
        return fmt.Errorf("DB_USER is required when DB_HOST is set")
    }
    return nil
}

type ValidatorContext

type ValidatorContext interface {
	Validate(ctx context.Context) error
}

ValidatorContext is like Validator but receives the context passed to Loader.LoadContext (or context.Background() for Loader.Load). The loader uses whichever of ValidatorContext / Validator the target struct implements — it can only be one (Go method names cannot be overloaded).

type Watcher

type Watcher interface {
	// Watch returns a channel that delivers Change events as the source's
	// underlying state changes. The returned channel MUST be closed when
	// ctx is canceled or when the source determines it can no longer
	// produce events (for example, the underlying file is deleted with
	// no re-bind possible). Each call to Watch returns an independent
	// subscription.
	Watch(ctx context.Context) (<-chan Change, error)
}

Watcher is optionally implemented by sources that can push change notifications. Sources that don't implement Watcher are polled at the interval set by WithPollInterval when used under Loader.Watch or Live; without that option, non-Watcher sources are read once at the initial load and never re-read.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL