cfgkit

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

cfgkit

Typed configuration from layered sources, for Go.

Defaults compiled in, so a valid configuration exists with zero input — and every value can tell you where it came from.

Go Reference on pkg.go.dev Built and tested on linux, macOS and Windows

A Go configuration library that binds environment variables, .env files, JSON, YAML, command-line flags, and any secret store you care to add into one validated, typed struct. Defaults live in Go and are compiled into the binary, so a complete valid configuration exists with zero input — no config file, no environment variable, no tool installed. Every source above that is an override of a value that already exists. It reports which source set each field, masks secrets structurally, validates without booting your application, and generates the .env.example contract from your struct.

go get github.com/ubgo/cfgkit                              # the library
go get github.com/ubgo/cfgkit/contrib/source-vault         # any adapter you want, separately

One dependency: github.com/ubgo/dotenv, which is itself stdlib-only. Minimum Go: 1.22 — checked by a CI job that builds and tests the core with a real 1.22 toolchain, not asserted here. Adapter modules under contrib/ are floored by the SDKs they wrap and may need more.

Three things ship here, and each installs on its own:

What it is Get it Docs
Library the loader, binder, validation, provenance and contract generation go get github.com/ubgo/cfgkit docs/
19 contrib modules YAML, TOML, HCL, INI, .properties, pflag, a ready-made cobra config command, Vault, Consul, etcd, Kubernetes, NATS, kiln, GCP, Azure, and four AWS services — each its own Go module, so you compile only what you import go get github.com/ubgo/cfgkit/contrib/<name> catalogue
22 runnable examples every source and pattern, each with a README and output pinned by a test go run ./examples/read-file examples/

Full documentation: docs/getting started · API reference · sources · tags · types · validation · provenance · modes · capabilities · recipes · writing an adapter

Contents: Quick start · What it does that others do not · Sources · Keys are declared, never guessed · Tags · Types · Hooks · Modes · contrib · Gotchas · Testing · FAQ

Quick start

type Config struct {
	Port     int           `env:"PORT"      default:"8080" doc:"HTTP listen port"`
	Timeout  time.Duration `env:"TIMEOUT"   default:"15s"`
	DBURL    string        `env:"DATABASE_URL,required"    doc:"Postgres connection string"`
	APIKey   string        `env:"API_KEY"   secret:"true"  doc:"Upstream API key"`
}

cfg, res, err := cfgkit.Load[Config](
	cfgkit.WithSources(
		cfgkit.FromFiles(".env", ".env.local"),
		cfgkit.FromEnviron(),   // last = highest precedence
	),
)

cfg is typed. res knows where every value came from. err reports every problem, not the first.

What it does that others do not

cfgkit viper koanf confx
Which source set this value Explain
Secret masking ✅ structural
Validate without booting Check
Generate .env.example Document
Valid config with zero input ✅ pinned by a test ⚠️
Flag defaults cannot beat a file #671 ⚠️
Reload without a caller-side mutex Watcher
Dependencies 1 many few several

The full matrix — six libraries, twenty rows, and where each alternative is the better choice — is in docs/comparison.md.

Where did this value come from?

The most common configuration question, and nothing else answers it:

res.Explain(os.Stdout)
FIELD    KEY           VALUE                     SOURCE
APIKey   API_KEY       ••••••                    map
DBURL    DATABASE_URL  postgres://localhost/dev  map
Port     PORT          9001                      map
Timeout  TIMEOUT       15s                       default

Secrets are absent, not styled out — the string never enters the output. Pass cfgkit.Reveal() when you actually want it.

Every snippet on this page is produced by a runnable example in example_test.go whose // Output: block is checked by go test. If the code's output changes, the test fails — so these cannot drift into fiction.

Fail in CI, not at container start

if err := cfgkit.Check[Config](opts...); err != nil {
	log.Fatal(err)
}

Runs the whole pipeline — sources, binding, derive, validate — without constructing an application. A stale environment file becomes a red build instead of a panic after deploy.

Generate the contract file

cfgkit.Document[Config](f)
# Upstream API key
# optional · secret — do not commit a real value
API_KEY=

# Postgres connection string
# REQUIRED
DATABASE_URL=

# HTTP listen port
# optional
PORT=8080

# Request timeout
# optional
TIMEOUT=15s

Output is byte-stable, so CI can regenerate and diff. Pair it with dotenvctl matrix --contract .env.example and the loop closes: struct → contract → every environment checked → red build.

Sources

Two shapes, because config data has two shapes.

Flat sources answer lookups by key and are matched by the env: tag:

cfgkit.FromEnviron()
cfgkit.FromPrefixedEnviron("SVC_")
cfgkit.FromFiles(".env", ".env.local")     // missing file is not an error
cfgkit.FromFlagSet(fs)                     // stdlib flag
cfgkit.FromMap(m)                          // the test seam

Structured sources merge nested data and are matched by the json: tag:

cfgkit.FromJSON(embeddedPkl)

Both live in one ordered list; a later source wins regardless of kind.

The conventional chain, in one call

cfg, res, err := cfgkit.Load[Config](cfgkit.DefaultSources())

.env.env.local.env.<mode>.env.<mode>.local → the process environment, every file optional. It also resolves the mode from those files, not only from the environment — so APP_ENV=production in a .env file actually selects prod strictness and the .env.prod file. Writing that chain by hand is where it goes wrong. Result.Files() reports what was consulted; Explain still names the exact file per field.

Any backend, in five lines

secrets, _ := vault.ReadAll(ctx, "secret/data/app")     // pre-load: Lookup runs once per field
src := cfgkit.SourceFunc("vault", func(key string) (string, bool, error) {
	v, ok := secrets[key]
	return v, ok, nil
})

Any format, in five lines

src := cfgkit.StructuredFunc("config.yaml", func(dst any) error {
	b, err := os.ReadFile("config.yaml")
	if err != nil {
		return err
	}
	return yaml.Unmarshal(b, dst)   // the yaml dependency is YOURS
})

The parser lives in your code, so cfgkit never has to add a format and never has to refuse one.

Keys are declared, never guessed

type HyperDX struct {
	LogsSourceID string `env:"HYPERDX_LOGS_SOURCE_ID"`
}

No library can split HYPERDX_LOGS_SOURCE_ID correctly — _ means both nesting and word break, so it has at least six valid readings. Declaring the key also means renaming a Go field can never silently change which variable feeds it, which is the failure that compiles, passes tests, and breaks production only.

Declare a prefix once on the parent when the repetition grates:

type Config struct {
	HyperDX HyperDX `env:",prefix=HYPERDX_"`
}

Tags

Tag Meaning
env:"KEY" the flat key that fills this field
env:",prefix=P_" on a struct: prepend to every key beneath it
env:"KEY,required" the key must resolve
env:"KEY,notempty" must resolve and not be empty — a different failure, with a different message
env:"KEY,file" the value is a path; read the file at it (Docker/Kubernetes secrets)
env:"KEY,unset" remove the key from the environment after reading, so children do not inherit it
env:"-" never bound; set by Derive or left at its default
json:"name" the nested path a structured source fills it from
default:"v" default, parsed by the field's decoder
secret:"true" mask everywhere
delim:";" slice element / map entry separator (default ,)
kvdelim:"=" map key-to-value separator (default :)
flag:"port" the command-line flag that may set it
was:"OLD_KEY" a former key name, so a rename does not break deployments
doc:"text" description, used by Document

Types

string, bool, every sized int/uint, floats, time.Duration, time.Time (RFC3339), and slices of those. Anything else implements encoding.TextUnmarshaler — one escape hatch, so the library never grows a type zoo. encoding.BinaryUnmarshaler is also accepted, because *url.URL implements only that form.

Maps too, for the one case a named field cannot serve — the key names are not known when you write the struct:

Flags map[string]string `env:"FLAGS"`   // FLAGS=new-checkout:on,dark-mode:off

Entries split on ,, key from value on the first : — so primary:postgres://db:5432/app survives intact. If the names are known, use named fields instead; a map gives up every guarantee the type system offers.

Hooks

func (c *Server) Defaults()        { c.Port = 8080 }          // before binding
func (c *Server) Derive() error    { c.DSN = build(c); ... }  // after binding
func (c *Server) Validate() error  { ... }                    // after deriving

All three run depth-first, children before parents. Validation is Go, not a tag language — so a renamed field is a compile error rather than a rule that silently stops matching.

func (p *PublicServe) Validate() error {
	return cfgkit.RequiredWhen(
		"Cloudflare", p.Cloudflare != nil,
		"kind=cloudflare", p.Kind == ServeKindCloudflare,
	)
}

That one call asserts both directions: required when the condition holds, forbidden when it does not.

Helpers: Required · NotEmpty · RequiredIn · RequiredWhen · OneOf · Range · Matches · MutuallyExclusive · AtLeastOneOf · NotWeakSecret.

Custom validators

Want a catalogue — email, URL, UUID, CIDR? Plug one into the same seam. The dependency stays in your module:

func (c *Config) Validate() error {
	return errors.Join(
		validator.New().Struct(c),                    // go-playground/validator tags
		cfgkit.RequiredWhen(...),                     // conditional logic in Go
	)
}

Tags for the catalogue rules, Go for the conditional ones, both joined so all errors surface at once.

Modes

One knob, not twenty booleans — because every independent flag creates a path nobody tests:

cfgkit.Load[Config](cfgkit.WithMode(cfgkit.ModeProd))

Resolved from APP_ENV when not passed; dev by default. The rule it enforces:

Every convenience that makes development frictionless must hard-fail in production.

cfgkit.NotWeakSecret(mode, "EncryptionKey", c.Key)  // fine in dev, refuses to boot in prod

contrib

Adapters carrying a dependency live in their own module, so you compile only what you import.

Module Adds Guide
contrib/format-hcl HCL documents and files README
contrib/format-ini INI documents and files — flat README
contrib/format-properties Java .propertiesflat README
contrib/format-toml TOML documents and files README
contrib/format-yaml YAML documents and files README
contrib/flags-pflag cobra / pflag flag sets README
contrib/source-azurekeyvault Azure Key Vault — no dependencies README
contrib/source-consul Consul KV prefixes — no dependencies README
contrib/source-etcd etcd v3 prefixes, via its HTTP gateway — no dependencies README
contrib/source-gcpsecrets Google Secret Manager — no dependencies README
contrib/source-k8s Kubernetes ConfigMaps and Secrets — no dependencies README
contrib/source-vault HashiCorp Vault KV secrets — no dependencies README
contrib/source-appconfig AWS AppConfig profiles README
contrib/source-s3 a config document in an S3 object README
contrib/source-secretsmanager AWS Secrets Manager README
contrib/source-ssm AWS Parameter Store README
contrib/source-kiln kiln-encrypted env files — the one source whose file is safe to commit README
contrib/source-nats NATS JetStream key/value buckets README

Every module's dependencies and support level: the catalogue.

import yamlsrc "github.com/ubgo/cfgkit/contrib/format-yaml"

cfgkit.WithSources(yamlsrc.File("config.yaml"), cfgkit.FromEnviron())

Writing your own? cfgkittest.RunSourceTests and RunStructuredTests are the shared conformance suite — pass them and your adapter behaves exactly like the built-ins. Full guide: writing an adapter.

Gotchas

Flags: Load must run after parsing. With cobra that means inside RunE, never init(). A flag set built too early holds nothing, contributes nothing, and looks like "flags do not work".

Flags have no defaults here. Declare cobra flags with a zero default and put the real one in Defaults(). Two defaults for one field is two sources of truth, and the flag's is invisible to Explain.

Structured sources replace slices. Nested structs merge field by field; slices and maps replace wholesale. Standard encoding/json behaviour.

Lookup runs once per field. A remote backend must pre-load or cache, or a 200-field config becomes 200 round-trips at boot.

Testing

Statement coverage — core 100.0%
Statement coverage — every format/flags module 100.0%
Statement coverage — source modules 96.9%–100.0%
Statement coverage — cli-cobra 97.1%
Test functions 678, plus 50 runnable examples, across 20 modules
Fuzz targets 23 — 6 core, 17 contrib
Dependencies 1

Re-measure any of it with task cover, which spans every module, or task test:uncovered for what is left per function.

Every code sample in this README is a runnable example in example_test.go whose // Output: block is checked by go test. The guard was verified by breaking it: change a character in an expected output and the test fails.

Invariants pinned by tests: zero-input, precedence, no environment mutation, secret masking, error completeness, idempotence, contract completeness, and the capability firewall. The core is at 100.0% statement coverage — every function, every branch — and so is every format and flags module.

The source modules sit between 96.9% and 100.0% and cli-cobra at 97.1%, and the sixteen uncovered statements are enumerated rather than rounded away — every one of them below, with the reason it cannot fire. They fall into four groups:

Not covered Sites Why it cannot fire
http.NewRequestWithContext's error return 8 The method is a constant and the URL is an already-parsed *url.URL. Checked by feeding prefixes containing newlines, NUL and DEL: URL.String() percent-encodes every one, so the request URI is always valid.
json.Marshal's error 4 Two in stringify, on a value that came out of json.Unmarshal moments earlier; two in source-etcd, on a map[string]string built three lines above. encoding/json fails on channels, functions and cycles — none of which these types can hold.
nc.JetStream()'s error in source-nats 1 The legacy client builds its context lazily. Checked against a server with JetStream disabled: this call still succeeds and the failure surfaces at KeyValue instead — which is pinned by its own test.
res.JSON()'s error and its caller in cli-cobra 2 Marshals cfgkit's own provenance record — a mode string, a field slice and a string slice. A typed envelope replaced an earlier map[string]any round-trip here, which deleted two further unreachable arms rather than excusing them.

None is deleted, because each guards a call that would return a nil value on failure, and the next caller is not guaranteed to be as lucky. Every one of them runs under task ci on Linux, macOS and Windows — including GOOS builds for all three, because a portability claim that nothing compiles for is a claim, not a fact. The same gate is defined as a GitHub Actions workflow in .github/workflows/ci.yml, currently manual-only: uncomment the push trigger to have it run on every commit. FuzzResolutionNeverPanics ran 6 million executions with no panic and no double-binding; re-run any target with task fuzz or all six with task fuzz:all.

The conformance harness is itself tested. Every adapter claims conformance by calling cfgkittest.RunSourceTests and passing, and that claim is worth exactly what the suite's ability to FAIL a bad source is worth. So the harness is fed five sources that each violate one guarantee — ignoring values, claiming keys it has none for, treating a cleared value as no opinion, reporting a backend failure as a miss, and zeroing fields a document omits — and each must be caught on the check that should catch it, not merely somewhere. The fixture set in mock/ is guarded the same way: a format added to the directory but left out of Fixtures would silently never be proved, so a test walks the embedded files and fails on the gap.

Every parser and every response path is fuzzed, not just the core. All five format modules take arbitrary bytes; the six HTTP-based sources are fed arbitrary response bodies through httptest; and the six that go through an SDK or library seam are fed arbitrary payloads through it. That is the case worth defending: a configuration source is trusted infrastructure right up until it is not — a compromised server, a truncated response, a proxy that injects an error page, an object half-written by a pipeline. None of it may crash the program trying to read its own configuration. task fuzz:contrib sweeps all seventeen, discovering targets from the source so a new one enrolls by existing.

Fuzzing is there because a table of edge cases is only as good as the imagination that wrote it. Two of the targets assert properties rather than the absence of a crash: a string field is a byte pipe (whatever goes in comes out, invalid UTF-8 and NUL included), and splitting a list then rejoining it reproduces the input. The second one earned its keep on its first run by finding that "0\r," round-trips to "0," — list elements are trimmed with TrimSpace, so a carriage return is stripped. That is the behaviour you want, since it makes a CRLF .env produce clean elements on Linux, but nothing had written it down.

The uncovered remainder is enumerable, not mystery. What is left is defensive: the addrOf guard for a value that cannot be addressed (unreachable through the public API, since Load always starts from an addressable struct), the forEachStruct branch for a nil pointer section, Explain's per-row write-error returns beyond the first, and the collectLeaves nil-section arm. Each is a line or two whose absence from the count is explained rather than ignored.

Reload

w, _ := cfgkit.NewWatcher[Config](func() []cfgkit.Option {
	return []cfgkit.Option{cfgkit.DefaultSources()}
})

cfg := w.Current()      // safe from any goroutine, no mutex
w.Reload()              // on your trigger: SIGHUP, a timer, an admin route

Reload runs the whole pipeline and validates before it publishes, so a bad edit is reported and the process keeps running on the configuration it already had. Readers take no lock, and a held value never changes underneath them. viper and koanf both tell you in their own docs to add a mutex; this needs none. One rule: a component holds the Watcher, never a value read from it — see Reload.

Runnable examples

Twenty-two of them, and every one actually runs — including the twelve that talk to a remote backend. There is no Vault, Consul, etcd, cluster, AWS account, GCP project or Azure subscription to set up first.

go run ./examples/read-file
go run ./examples/read-vault      # starts a fake Vault in-process
go run ./examples/read-nats       # starts a REAL nats-server in-process
Start here read-file, read-environment, default-values, precedence
Local sources read-json, read-struct, read-formats, read-commandline
Remote sources read-vault, read-consul, read-etcd, read-k8s, read-nats, read-kiln, read-gcpsecrets, read-azkeyvault, read-s3, read-parameterstore, read-secretsmanager, read-appconfig
Auditing provenance, validation
Patterns plugin — a plugin configuring itself when the host cannot name its config type, the case viper.Sub() exists for, answered without an API

Every example's output is pinned by a test and run by task ci, so documentation that drifts from the code fails the build instead of quietly becoming a lie. Full index and conventions: examples/README.md.

FAQ

Does it read my config file at startup with a global Get()? No. There is no package-level state and no Get. Load returns a value your code owns and passes where it is needed — so tests can run in parallel with different configurations, and a library can never depend on a hidden singleton.

Is there a Sub() / Cut() for handing part of the config to a component? No, and it is not missing. viper and koanf hold an untyped map[string]any, so Sub is how you cut out a piece for a component to unmarshal. Here the tree is typed: when you know the component's type you pass the field (startEmailSender(cfg.SMTP)), and when you do not — a plugin — the host passes a source and the plugin runs its own Load, bringing its own type, defaults, validation, secrets and provenance. See examples/plugin.

How is this different from viper or koanf? Five capabilities neither has: Explain reports which source set each field, secrets are structurally masked, Check validates without constructing your application, and Document generates the .env.example contract from your struct. Reload is safe with no lock in the caller, where both of them tell you in their own documentation to add a mutex. cfgkit also has one dependency where viper has many.

Why doesn't it guess the key from my field name? Because it cannot be done correctly. HYPERDX_LOGS_SOURCE_ID has at least six valid readings, since _ separates both nesting levels and words within a name. Declaring the key also means renaming a Go field can never silently change which environment variable feeds it.

Can I use YAML or TOML? Yes — either through contrib/format-yaml, or in five lines of your own code with StructuredFunc, keeping the parser dependency in your module rather than in cfgkit.

Does it support Vault, AWS Secrets Manager, or Kubernetes secrets? All three ship as dedicated modules, along with Consul, etcd, NATS, kiln, Google Secret Manager, Azure Key Vault, S3, Parameter Store and AppConfig — the full catalogue. Vault, Consul, etcd, Kubernetes, GCP and Azure carry no dependencies at all, because on those platforms a credential is a file or a header. For mounted secrets, env:"KEY,file" reads the value from the path the variable points at, and source-k8s can read a projected volume directly with no API permission. Anything not in the catalogue is still five lines behind SourceFunc.

Can I get check / explain / document as commands in my own CLI? Yes — contrib/cli-cobra mounts them on any cobra root in one line, bound to your config type. They open no database and no network connection, so they still run on a machine where the application itself could not start, which is when you need them. They cannot ship as a prebuilt binary: the three verbs are generic over your struct, so the code calling them has to be compiled against it.

Will it change my process environment? No, with one opt-in exception: a field tagged unset removes its own key after reading, so a child process cannot inherit the secret. That exception is pinned by a test.

How do I validate with go-playground/validator? Call it inside your Validate() method. The Validator interface is a seam, so the catalogue rules are available with the dependency in your module and not in cfgkit's.

Does it support hot reload? Yes, through Watcher[T] — and without the mutex both alternatives require. It publishes an immutable snapshot behind an atomic pointer and validates before publishing, so a bad edit is reported and the previous configuration stays in service. What it deliberately does not do is watch files: that needs fsnotify, so the trigger is yours — SIGHUP, a timer, an admin route, or your own watcher. See Reload.

License

Apache-2.0

cfgkit is a typed Go configuration library — layered sources, compiled-in defaults, environment variables, .env files, JSON, YAML, command-line flags, secret stores, cross-field validation, secret masking, provenance reporting, and .env.example generation. Apache-2.0 licensed.

Documentation

Overview

Package cfgkit turns layered sources into a validated, typed Go struct.

The property everything else hangs off: a complete valid configuration exists with ZERO input. Defaults live in Go and are compiled into the binary, so a program runs on a machine with no config file, no environment variable, and no tool installed. Every source is an override of a value that already exists.

Sources come in two shapes, because config data does. Flat sources answer lookups by key (.env files, the process environment, a secret store); structured sources merge nested data onto the struct (JSON, and therefore Pkl or any other nested format). They cannot be collapsed into one: flattening {"hyperdx":{"logsSourceId":…}} yields a key that matches no environment variable, and deriving HYPERDX_LOGS_SOURCE_ID from it is impossible because "_" means both nesting and word break.

cfgkit deliberately has no global state and no package-level Get. Load returns a value the caller owns. There is nowhere to put hidden state, which is a stronger guarantee than the discipline not to use it.

Example (Check)

Example_check shows the CI gate: the full pipeline runs, every problem is reported, and no application is constructed.

package main

import (
	"fmt"
	"time"

	"github.com/ubgo/cfgkit"
)

// ExampleConfig is the struct the examples below load. Every example in this
// file is verified by `go test`: its Output block must match byte for byte, so
// the snippets in README.md cannot drift from what the code actually prints.
type ExampleConfig struct {
	Port    int           `env:"PORT" default:"8080" doc:"HTTP listen port"`
	Timeout time.Duration `env:"TIMEOUT" default:"15s" doc:"Request timeout"`
	DBURL   string        `env:"DATABASE_URL,required" doc:"Postgres connection string"`
	APIKey  string        `env:"API_KEY" secret:"true" doc:"Upstream API key"`
}

func main() {
	err := cfgkit.Check[ExampleConfig](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"PORT": "eighty"}),
	))
	fmt.Println(err)

}
Output:
cfgkit: 2 problem(s):
Port (PORT from map): "eighty" is not a valid int
DBURL (DATABASE_URL) is required but no source supplied it
Example (ConditionalRule)

Example_conditionalRule shows one rule call asserting BOTH directions of a discriminated-union invariant.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

// ServeMode is the discriminant of a tagged union — a real Go type, so a
// comparison against it cannot be a mistyped string.
type ServeMode string

const ServeCloudflare ServeMode = "cloudflare"

type PurgeConfig struct {
	Token string `env:"TOKEN"`
}

type Serving struct {
	Kind       ServeMode    `env:"SERVE_KIND" default:"direct"`
	Cloudflare *PurgeConfig `env:",prefix=CF_"`
}

func (s *Serving) Validate() error {
	return cfgkit.RequiredWhen(
		"Cloudflare", s.Cloudflare != nil,
		"kind=cloudflare", s.Kind == ServeCloudflare,
	)
}

func main() {
	missing := cfgkit.Check[Serving](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"SERVE_KIND": "cloudflare"}),
	))
	fmt.Println(missing)

	forbidden := cfgkit.Check[Serving](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"SERVE_KIND": "direct", "CF_TOKEN": "t"}),
	))
	fmt.Println(forbidden)

	ok := cfgkit.Check[Serving](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"SERVE_KIND": "cloudflare", "CF_TOKEN": "t"}),
	))
	fmt.Println("valid:", ok)

}
Output:
cfgkit: 1 problem(s):
Cloudflare: is required when kind=cloudflare, but it was not set (required_when)
cfgkit: 1 problem(s):
Cloudflare: must not be set unless kind=cloudflare (required_when)
valid: <nil>
Example (CustomSource)

Example_customSource shows that any flat backend is a closure, with its dependency staying in the caller.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Secret string `env:"APP_SECRET" secret:"true"`
	}

	// Pre-load once: Lookup is called per field, so a remote store must cache.
	store := map[string]string{"APP_SECRET": "from-vault"}
	src := cfgkit.SourceFunc("vault", func(key string) (string, bool, error) {
		v, ok := store[key]
		return v, ok, nil
	})

	_, res, _ := cfgkit.Load[Config](cfgkit.WithSources(src), cfgkit.Reveal())
	fmt.Printf("%s=%s from %s\n", res.Fields()[0].Key, res.Fields()[0].Value, res.Fields()[0].Source)

}
Output:
APP_SECRET=from-vault from vault
Example (DefaultSources)

Example_defaultSources shows the conventional chain replacing the twelve lines every service otherwise writes identically.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/cfgkit"
)

func main() {
	// A directory standing in for the working directory.
	dir, _ := os.MkdirTemp("", "cfgkit")
	defer func() { _ = os.RemoveAll(dir) }()
	_ = os.WriteFile(filepath.Join(dir, ".env"), []byte(
		"APP_ENV=production\nEX_HOST=from-base\n"), 0o600)
	_ = os.WriteFile(filepath.Join(dir, ".env.prod"), []byte(
		"EX_HOST=from-prod-file\n"), 0o600)

	type Config struct {
		Host string `env:"EX_HOST" default:"localhost"`
		Port int    `env:"EX_PORT" default:"8080"`
	}

	// One call: the .env chain for the resolved mode, then the environment.
	cfg, res, _ := cfgkit.Load[Config](cfgkit.DefaultSourcesIn(dir))

	// APP_ENV lives in a FILE, and it still selects the mode — which then
	// selects which .env.<mode> file is loaded.
	fmt.Println("mode:", res.Mode())
	fmt.Println("host:", cfg.Host)
	fmt.Println("port:", cfg.Port)
	for _, f := range res.Files() {
		fmt.Println("consulted:", filepath.Base(f))
	}

}
Output:
mode: prod
host: from-prod-file
port: 8080
consulted: .env
consulted: .env.local
consulted: .env.prod
consulted: .env.prod.local
Example (Derive)

Example_derive shows a value computed from other fields, and that Validate sees the derived result.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	cfg, _, err := cfgkit.Load[derived](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"DB_HOST": "db.internal"}),
	))
	fmt.Println(cfg.DSN, "err:", err)

}

type derived struct {
	Host string `env:"DB_HOST"`
	Port int    `env:"DB_PORT"`
	DSN  string `env:"-"`
}

func (d *derived) Defaults() { d.Host, d.Port = "localhost", 5432 }

func (d *derived) Derive() error {
	d.DSN = fmt.Sprintf("postgres://%s:%d/app", d.Host, d.Port)
	return nil
}

func (d *derived) Validate() error {
	return cfgkit.Range("Port", d.Port, 1, 65535)
}
Output:
postgres://db.internal:5432/app err: <nil>
Example (Document)

Example_document shows the generated contract file. Secrets are emitted with an empty value, because the file is committed.

package main

import (
	"os"
	"time"

	"github.com/ubgo/cfgkit"
)

// ExampleConfig is the struct the examples below load. Every example in this
// file is verified by `go test`: its Output block must match byte for byte, so
// the snippets in README.md cannot drift from what the code actually prints.
type ExampleConfig struct {
	Port    int           `env:"PORT" default:"8080" doc:"HTTP listen port"`
	Timeout time.Duration `env:"TIMEOUT" default:"15s" doc:"Request timeout"`
	DBURL   string        `env:"DATABASE_URL,required" doc:"Postgres connection string"`
	APIKey  string        `env:"API_KEY" secret:"true" doc:"Upstream API key"`
}

func main() {
	// A write failure here would make the Output comparison below fail anyway,
	// so the error is discarded explicitly rather than handled twice.
	_ = cfgkit.Document[ExampleConfig](os.Stdout)

}
Output:
# Upstream API key
# optional · secret — do not commit a real value
API_KEY=

# Postgres connection string
# REQUIRED
DATABASE_URL=

# HTTP listen port
# optional
PORT=8080

# Request timeout
# optional
TIMEOUT=15s
Example (Explain)

Example_explain shows the provenance table: every field, its value, and the source that set it. This is the question no other Go config library answers.

package main

import (
	"fmt"
	"os"
	"time"

	"github.com/ubgo/cfgkit"
)

// ExampleConfig is the struct the examples below load. Every example in this
// file is verified by `go test`: its Output block must match byte for byte, so
// the snippets in README.md cannot drift from what the code actually prints.
type ExampleConfig struct {
	Port    int           `env:"PORT" default:"8080" doc:"HTTP listen port"`
	Timeout time.Duration `env:"TIMEOUT" default:"15s" doc:"Request timeout"`
	DBURL   string        `env:"DATABASE_URL,required" doc:"Postgres connection string"`
	APIKey  string        `env:"API_KEY" secret:"true" doc:"Upstream API key"`
}

func main() {
	_, res, err := cfgkit.Load[ExampleConfig](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{
			"DATABASE_URL": "postgres://localhost/dev",
			"API_KEY":      "super-secret-value",
		}),
		cfgkit.FromMap(map[string]string{"PORT": "9001"}),
	))
	if err != nil {
		fmt.Println(err)
		return
	}
	_ = res.Explain(os.Stdout)

}
Output:
FIELD    KEY           VALUE                     SOURCE
APIKey   API_KEY       ••••••                    map
DBURL    DATABASE_URL  postgres://localhost/dev  map
Port     PORT          9001                      map
Timeout  TIMEOUT       15s                       default
Example (FileValue)

Example_fileValue shows the Docker and Kubernetes secret convention: the variable holds a PATH, and the value is the file's contents.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Password string `env:"DB_PASSWORD_FILE,file" secret:"true"`
	}

	// Setup failures panic rather than being discarded: an Example that
	// silently proceeds on a missing fixture reports a pass for the wrong
	// reason, which is worse than a crash.
	dir, err := os.MkdirTemp("", "cfgkit")
	if err != nil {
		panic(err)
	}
	// Cleanup failure is not actionable and must not mask the real result.
	defer func() { _ = os.RemoveAll(dir) }()

	path := filepath.Join(dir, "pw")
	if err := os.WriteFile(path, []byte("s3cret\n"), 0o600); err != nil { // note the trailing newline
		panic(err)
	}

	cfg, _, err := cfgkit.Load[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"DB_PASSWORD_FILE": path}),
	))
	fmt.Printf("%q err=%v\n", cfg.Password, err)

}
Output:
"s3cret" err=<nil>
Example (FlagDefaultNeverWins)

Example_flagDefaultNeverWins shows the rule viper gets wrong: a flag the user did not type contributes nothing, so a config source still wins.

package main

import (
	"flag"
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Port int `env:"PORT" flag:"port" default:"8080"`
	}

	fs := flag.NewFlagSet("app", flag.ContinueOnError)
	fs.Int("port", 9999, "port") // declared with a default, never typed
	if err := fs.Parse(nil); err != nil {
		panic(err)
	}

	cfg, res, _ := cfgkit.Load[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"PORT": "3000"}),
		cfgkit.FromFlagSet(fs), // highest precedence
	))
	fmt.Printf("Port=%d from=%s\n", cfg.Port, res.Fields()[0].Source)

}
Output:
Port=3000 from=map
Example (FormerKeyName)

Example_formerKeyName shows that renaming a key keeps old deployments working, and that the provenance flags the deprecated name.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		APIKey string `env:"HYPERDX_API_KEY" was:"HYPERDX_KEY"`
	}

	cfg, res, _ := cfgkit.Load[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"HYPERDX_KEY": "from-old-name"}),
	))
	fmt.Println(cfg.APIKey)
	fmt.Println(res.Fields()[0].Source)

}
Output:
from-old-name
map (deprecated key HYPERDX_KEY)
Example (MapFields)

Example_mapFields shows the one case a map is for: the KEY NAMES are not known when the struct is written, so a new entry is added by editing a .env file rather than the Go source.

package main

import (
	"fmt"
	"time"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		// Feature flags: nobody can list them at compile time.
		Flags map[string]string `env:"FLAGS"`
		// Per-tenant limits, decoded through the same decoder an int field uses.
		Limits map[string]int `env:"LIMITS"`
		// Timeouts, likewise — any supported type works as the value.
		Timeouts map[string]time.Duration `env:"TIMEOUTS"`
	}

	cfg, _, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{
		"FLAGS":    "new-checkout:on,dark-mode:off",
		"LIMITS":   "acme:1000,globex:500",
		"TIMEOUTS": "read:30s,write:1m",
	})))

	fmt.Printf("new-checkout=%s dark-mode=%s\n", cfg.Flags["new-checkout"], cfg.Flags["dark-mode"])
	fmt.Printf("acme=%d globex=%d\n", cfg.Limits["acme"], cfg.Limits["globex"])
	fmt.Printf("read=%s write=%s\n", cfg.Timeouts["read"], cfg.Timeouts["write"])

}
Output:
new-checkout=on dark-mode=off
acme=1000 globex=500
read=30s write=1m0s
Example (MapValuesMayContainColons)

Example_mapValuesMayContainColons is the gotcha worth memorising: only the FIRST separator in an entry splits key from value, so a connection string survives intact.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		DSNs map[string]string `env:"DSNS"`
	}

	cfg, _, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{
		"DSNS": "primary:postgres://user@db1:5432/app,cache:redis://cache:6379",
	})))

	fmt.Println(cfg.DSNs["primary"])
	fmt.Println(cfg.DSNs["cache"])

}
Output:
postgres://user@db1:5432/app
redis://cache:6379
Example (ModeStrictness)

Example_modeStrictness shows the rule that makes zero-config safe: the value that lets a fresh clone run must refuse to boot in production.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	const placeholder = "__CHANGE_ME__"

	fmt.Println("dev: ", cfgkit.NotWeakSecret(cfgkit.ModeDev, "Key", placeholder))
	fmt.Println("prod:", cfgkit.NotWeakSecret(cfgkit.ModeProd, "Key", placeholder))

}
Output:
dev:  <nil>
prod: Key: is still a placeholder in mode=prod; set a real value (not_weak_secret)
Example (OptionalSection)

Example_optionalSection shows the rule from CONFIG_SPEC §6.4: a *Struct is nil unless a source set something beneath it, so the ordinary Go nil check means "the operator configured this feature".

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

// SMTPSection is an optional feature: nil means this app does not send email.
type SMTPSection struct {
	Host string `env:"HOST"`
	User string `env:"USER"`
	Port int    `env:"PORT" default:"587"`
}

type AppWithOptional struct {
	Port int          `env:"APP_PORT" default:"8080"`
	SMTP *SMTPSection `env:",prefix=SMTP_"`
}

func main() {
	// 1. Nobody configured email.
	none, _, _ := cfgkit.Load[AppWithOptional]()
	fmt.Printf("nothing set:      SMTP == nil? %v\n", none.SMTP == nil)

	// 2. One field is enough — setting SMTP_HOST states intent.
	one, _, _ := cfgkit.Load[AppWithOptional](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"SMTP_HOST": "mail.example.com"}),
	))
	fmt.Printf("SMTP_HOST set:    SMTP == nil? %v  Host=%q Port=%d\n",
		one.SMTP == nil, one.SMTP.Host, one.SMTP.Port)

	// 3. A default alone is NOT intent. SMTPSection.Port has default:"587",
	//    and that does not bring the section into existence.
	other, _, _ := cfgkit.Load[AppWithOptional](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"APP_PORT": "9000"}),
	))
	fmt.Printf("only APP_PORT:    SMTP == nil? %v\n", other.SMTP == nil)

}
Output:
nothing set:      SMTP == nil? true
SMTP_HOST set:    SMTP == nil? false  Host="mail.example.com" Port=587
only APP_PORT:    SMTP == nil? true
Example (OptionalSectionInit)

Example_optionalSectionInit shows the override: a caller reads cfg.Cache without a nil guard.

package main

import (
	"fmt"
	"time"

	"github.com/ubgo/cfgkit"
)

// CacheSection's defaults are a complete working setup, so it opts into always
// being allocated.
type CacheSection struct {
	TTL  time.Duration `env:"TTL" default:"5m"`
	Size int           `env:"SIZE" default:"1000"`
}

type AppWithInit struct {
	Cache *CacheSection `env:",prefix=CACHE_,init"`
}

func main() {
	cfg, _, _ := cfgkit.Load[AppWithInit]()
	fmt.Printf("Cache == nil? %v  TTL=%v Size=%d\n", cfg.Cache == nil, cfg.Cache.TTL, cfg.Cache.Size)

}
Output:
Cache == nil? false  TTL=5m0s Size=1000
Example (Precedence)

ExamplePrecedence shows that the LAST source claiming a key wins, and that a key only an earlier source sets still applies.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Port int    `env:"PORT" default:"8080"`
		Host string `env:"HOST" default:"localhost"`
		Name string `env:"NAME" default:"app"`
	}

	cfg, res, _ := cfgkit.Load[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"PORT": "1111", "HOST": "from-first"}),
		cfgkit.FromMap(map[string]string{"PORT": "2222"}),
	))

	fmt.Printf("Port=%d Host=%s Name=%s\n", cfg.Port, cfg.Host, cfg.Name)
	for _, f := range res.Fields() {
		fmt.Printf("%-5s %s\n", f.Path, f.Source)
	}

}
Output:
Port=2222 Host=from-first Name=app
Host  map
Name  default
Port  map
Example (RequiredVsNotEmpty)

Example_requiredVsNotEmpty shows that a missing key and a declared-but-empty value are different failures, because they need different fixes.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Token string `env:"TOKEN,required"`
		Name  string `env:"NAME,notempty"`
	}

	fmt.Println(cfgkit.Check[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"NAME": "x"}),
	)))
	fmt.Println(cfgkit.Check[Config](cfgkit.WithSources(
		cfgkit.FromMap(map[string]string{"TOKEN": "t", "NAME": ""}),
	)))

}
Output:
cfgkit: 1 problem(s):
Token (TOKEN) is required but no source supplied it
cfgkit: 1 problem(s):
Name (NAME) is required but resolved to an empty value
Example (SecretsAreAbsent)

Example_secretsAreAbsent shows that masking is structural: the value is not styled out, it never enters the output at all. Pass Reveal to opt in.

package main

import (
	"fmt"
	"time"

	"github.com/ubgo/cfgkit"
)

// ExampleConfig is the struct the examples below load. Every example in this
// file is verified by `go test`: its Output block must match byte for byte, so
// the snippets in README.md cannot drift from what the code actually prints.
type ExampleConfig struct {
	Port    int           `env:"PORT" default:"8080" doc:"HTTP listen port"`
	Timeout time.Duration `env:"TIMEOUT" default:"15s" doc:"Request timeout"`
	DBURL   string        `env:"DATABASE_URL,required" doc:"Postgres connection string"`
	APIKey  string        `env:"API_KEY" secret:"true" doc:"Upstream API key"`
}

func main() {
	src := cfgkit.FromMap(map[string]string{
		"DATABASE_URL": "postgres://localhost/dev",
		"API_KEY":      "super-secret-value",
	})

	_, masked, _ := cfgkit.Load[ExampleConfig](cfgkit.WithSources(src))
	b, _ := masked.JSON()
	fmt.Println("default:", string(b))

	_, revealed, _ := cfgkit.Load[ExampleConfig](cfgkit.WithSources(src), cfgkit.Reveal())
	b, _ = revealed.JSON()
	fmt.Println("reveal: ", string(b))

}
Output:
default: {"mode":"dev","fields":[{"path":"APIKey","key":"API_KEY","value":"••••••","source":"map","secret":true},{"path":"DBURL","key":"DATABASE_URL","value":"postgres://localhost/dev","source":"map","secret":false},{"path":"Port","key":"PORT","value":"8080","source":"default","secret":false},{"path":"Timeout","key":"TIMEOUT","value":"15s","source":"default","secret":false}]}
reveal:  {"mode":"dev","fields":[{"path":"APIKey","key":"API_KEY","value":"super-secret-value","source":"map","secret":true},{"path":"DBURL","key":"DATABASE_URL","value":"postgres://localhost/dev","source":"map","secret":false},{"path":"Port","key":"PORT","value":"8080","source":"default","secret":false},{"path":"Timeout","key":"TIMEOUT","value":"15s","source":"default","secret":false}]}
Example (SlicesAndDurations)

Example_timeout documents the duration and slice decoders together.

package main

import (
	"fmt"
	"time"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Timeout time.Duration `env:"TIMEOUT" default:"30s"`
		Origins []string      `env:"ORIGINS"`
		Ports   []int         `env:"PORTS" delim:";"`
	}

	cfg, _, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{
		"TIMEOUT": "2m30s",
		"ORIGINS": "https://a.test, https://b.test",
		"PORTS":   "80;443",
	})))
	fmt.Println(cfg.Timeout, cfg.Origins, cfg.Ports)

}
Output:
2m30s [https://a.test https://b.test] [80 443]
Example (SourceFailureAborts)

Example_sourceFailureAborts shows that an unreachable backend is never a miss: it aborts, so a deploy cannot proceed with an empty password.

package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Secret string `env:"APP_SECRET"`
	}

	down := cfgkit.SourceFunc("vault", func(string) (string, bool, error) {
		return "", false, errors.New("connection refused")
	})

	err := cfgkit.Check[Config](cfgkit.WithSources(down))
	var se *cfgkit.SourceError
	fmt.Println(errors.As(err, &se), err)

}
Output:
true cfgkit: 1 problem(s):
source vault failed for APP_SECRET: connection refused
Example (StructuredAndFlat)

Example_structuredAndFlat shows both source kinds in one chain: nested data matched by json tags, flat keys matched by env tags, later source winning.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type HyperDX struct {
		LogsSourceID string `env:"LOGS_SOURCE_ID" json:"logsSourceId"`
		APIKey       string `env:"API_KEY"        json:"apiKey"`
	}
	type Config struct {
		HyperDX HyperDX `env:",prefix=HYPERDX_" json:"hyperdx"`
	}

	cfg, res, _ := cfgkit.Load[Config](cfgkit.WithSources(
		cfgkit.FromJSON([]byte(`{"hyperdx":{"logsSourceId":"from-json","apiKey":"from-json"}}`)),
		cfgkit.FromMap(map[string]string{"HYPERDX_API_KEY": "from-env"}),
	))

	fmt.Println(cfg.HyperDX.LogsSourceID, cfg.HyperDX.APIKey)
	for _, f := range res.Fields() {
		fmt.Printf("%-22s %-24s %s\n", f.Path, f.Key, f.Source)
	}

}
Output:
from-json from-env
HyperDX.APIKey         HYPERDX_API_KEY          map
HyperDX.LogsSourceID   HYPERDX_LOGS_SOURCE_ID   json
Example (UnknownKeys)

Example_unknownKeys shows the mirror of Explain: not "where did this value come from" but "why did my value go nowhere".

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		DatabaseURL string `env:"DATABASE_URL" default:"postgres://localhost/dev"`
		Port        int    `env:"PORT" default:"8080"`
	}

	// A .env file with a typo on the first key.
	_, res, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{
		"DATABAS_URL": "postgres://prod-db.internal/app", // typo: missing the E
		"PORT":        "9000",
	})))

	for _, u := range res.Unknown() {
		fmt.Println(u)
	}

}
Output:
DATABAS_URL (from map) matched no field
Example (UnknownKeysIgnoresEnviron)

Example_unknownKeysIgnoresEnviron shows why FromEnviron is excluded: its key set is the whole machine, so reporting it would bury the line that matters.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Port int `env:"PORT" default:"8080"`
	}

	_, res, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromEnviron()))
	fmt.Printf("unknown keys reported: %d\n", len(res.Unknown()))

}
Output:
unknown keys reported: 0
Example (Watcher)

Example_watcher shows the reload contract: a bad edit is reported and the process keeps running on the configuration it already had.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/ubgo/cfgkit"
)

func main() {
	dir, _ := os.MkdirTemp("", "cfgkit")
	defer func() { _ = os.RemoveAll(dir) }()
	env := filepath.Join(dir, ".env")
	write := func(body string) { _ = os.WriteFile(env, []byte(body), 0o600) }

	type Config struct {
		Host string `env:"EX_HOST" default:"localhost"`
		Port int    `env:"EX_PORT" default:"8080"`
	}

	write("EX_HOST=first\nEX_PORT=9000\n")

	// The sources are built INSIDE the function, which is what makes a reload
	// re-read the file: FromFiles reads at construction.
	w, err := cfgkit.NewWatcher[Config](func() []cfgkit.Option {
		return []cfgkit.Option{cfgkit.WithSources(cfgkit.FromFiles(env))}
	})
	if err != nil {
		panic(err)
	}
	fmt.Printf("gen %d: %s:%d\n", w.Generation(), w.Current().Host, w.Current().Port)

	// A good edit is published.
	write("EX_HOST=second\nEX_PORT=9001\n")
	fmt.Println("reload:", w.Reload())
	fmt.Printf("gen %d: %s:%d\n", w.Generation(), w.Current().Host, w.Current().Port)

	// A bad edit is refused, and the process keeps the configuration it had.
	write("EX_HOST=third\nEX_PORT=not-a-number\n")
	fmt.Println("reload:", w.Reload() != nil)
	fmt.Printf("gen %d: %s:%d\n", w.Generation(), w.Current().Host, w.Current().Port)

}
Output:
gen 1: first:9000
reload: <nil>
gen 2: second:9001
reload: true
gen 2: second:9001
Example (ZeroInput)

Example_zeroInput shows the property everything else hangs off: no sources, no environment, and the configuration is still complete and usable.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Server struct {
		Host string `env:"HOST" default:"localhost"`
		Port int    `env:"PORT" default:"8080"`
	}

	cfg, _, err := cfgkit.Load[Server]()
	fmt.Printf("%s:%d err=%v\n", cfg.Host, cfg.Port, err)

}
Output:
localhost:8080 err=<nil>
Example (ZeroValuesOverrideDefaults)

Example_zeroValuesOverrideDefaults shows the rule that makes a default of `true` disableable: a source wins even when its value is the type's zero.

package main

import (
	"fmt"

	"github.com/ubgo/cfgkit"
)

func main() {
	type Config struct {
		Host    string   `env:"Z_HOST" default:"localhost"`
		Debug   bool     `env:"Z_DEBUG" default:"true"`
		Origins []string `env:"Z_ORIGINS" default:"a,b"`
	}

	// The operator deliberately clears the host, turns debug off, and allows
	// no origins. Every one of these is a zero value.
	cfg, _, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{
		"Z_HOST":    "",
		"Z_DEBUG":   "false",
		"Z_ORIGINS": "",
	})))
	fmt.Printf("Host=%q Debug=%v Origins=%v\n", cfg.Host, cfg.Debug, cfg.Origins)

	// A key NO source mentions keeps its default — absent and empty differ.
	other, _, _ := cfgkit.Load[Config](cfgkit.WithSources(cfgkit.FromMap(map[string]string{})))
	fmt.Printf("Host=%q Debug=%v Origins=%v\n", other.Host, other.Debug, other.Origins)

}
Output:
Host="" Debug=false Origins=[]
Host="localhost" Debug=true Origins=[a b]

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func AtLeastOneOf

func AtLeastOneOf(fields ...Set) error

AtLeastOneOf reports an error when none of the named fields is set.

func Check

func Check[T any](opts ...Option) error

Check runs the whole pipeline and reports problems without returning the configuration.

It exists so config errors fail a pipeline instead of a production boot. In both projects this package was written for, a stale environment file surfaced as a panic at container start — after deploy, and before observability existed to record it.

func Document

func Document[T any](w io.Writer, opts ...Option) error

Document writes a .env.example describing every key the configuration binds.

The struct already holds every fact such a file needs: the key, whether it is required, the default, whether it is a secret, and a description. Generating it removes the only way a contract file can drift from the code — a human keeping two things in sync by hand. sync_go's committed sample still hardcodes a developer's absolute path for exactly that reason.

This closes a loop no other toolchain has: the struct writes the contract, and `dotenvctl matrix --contract .env.example` then fails CI when any environment lacks a key the contract declares.

struct → .env.example → dotenvctl matrix --contract → red build

Secrets are emitted with an EMPTY value and a warning comment. A contract file is committed, so it must never carry a real credential — and a generator that wrote one would be a credential leak with a schedule.

func Matches

func Matches(field, v string, re *regexp.Regexp) error

Matches reports an error when v does not match re.

func MutuallyExclusive

func MutuallyExclusive(fields ...Set) error

MutuallyExclusive reports an error when more than one of the named fields is set. Pass field names and their values in pairs via Set.

func NotEmpty

func NotEmpty(field string, v string) error

NotEmpty reports an error when v is an empty string.

It is separate from Required because the two failures have different causes and different fixes: a missing key needs a new line in the deployment, an empty one needs a value in a line that already exists.

func NotWeakSecret

func NotWeakSecret(current Mode, field, v string, known ...string) error

NotWeakSecret rejects a value that is empty, a __PLACEHOLDER__, or one of the caller's known template strings — but only outside development.

In dev the same value is fine and must stay fine, because that is what lets a fresh clone run with no setup. The whole point is that the convenience cannot survive to production silently.

func OneOf

func OneOf[T comparable](field string, v T, allowed ...T) error

OneOf reports an error when v is not in allowed.

Prefer a typed constant with its own UnmarshalText where the set is fixed and known at compile time — that turns a bad value into a decode error naming the field, one layer earlier. OneOf is for sets that are only known at runtime.

func Range

func Range[T int | int8 | int16 | int32 | int64 | float32 | float64](field string, v, lo, hi T) error

Range reports an error when v falls outside [lo, hi] inclusive.

func Required

func Required(field string, v any) error

Required reports an error when v is the zero value for its type.

func RequiredIn

func RequiredIn(mode, current Mode, field string, v any) error

RequiredIn enforces Required only when running in mode.

This is the mechanism behind the rule that makes zero-config safe: a convenience that keeps development frictionless must hard-fail in production rather than being silently accepted. A generated dev secret is delightful on a laptop and catastrophic if it survives to prod.

func RequiredWhen

func RequiredWhen(field string, present bool, condition string, holds bool) error

RequiredWhen asserts that a field is present exactly when a condition holds, and absent otherwise. It produces BOTH directions of the invariant from one call, with a distinct message for each.

This is the discriminated-union case that only a config language could express before: "cloudflare is required when kind is cloudflare, and must not be set otherwise". In Go the discriminant is a real typed constant, so the comparison cannot be a mistyped string literal — which is more than the tag form can promise.

Types

type DecodeError

type DecodeError struct {
	Path   string // "Server.Port"
	Key    string // "PORT"
	Source string // "file:.env.local"
	// Value is the raw text, for programmatic handling only. It is EMPTY when
	// the field is secret, and it is deliberately not part of Error(): the
	// message would then carry the value into whatever logs the error.
	Value string
	// Err is the underlying failure. For a secret field it is rewritten to name
	// the expected TYPE and nothing else, because a decoder's own message
	// quotes the offending text.
	Err error
}

DecodeError reports a value that could not be parsed into its field's type.

func (*DecodeError) Error

func (e *DecodeError) Error() string

Error renders "Path (KEY from source): cause".

The offending VALUE is deliberately absent. For a secret field the wrapped cause is rewritten to name the expected type, because a decoder's own message quotes the text it choked on — which is how a credential reaches a log aggregator.

func (*DecodeError) Unwrap

func (e *DecodeError) Unwrap() error

Unwrap exposes the decoder's own failure to errors.Is and errors.As. For a secret field this is the rewritten cause, never the one quoting the value.

type Decoder

type Decoder interface {
	// Decode parses raw into a value assignable to typ.
	//
	// Return (value, true, nil) to claim the type, (nil, false, nil) to decline
	// so the next decoder or the core handles it, and (nil, true, err) to claim
	// it and report a bad value.
	Decode(typ reflect.Type, raw string) (any, bool, error)
}

Decoder converts a raw string into a value of a type the core does not handle, or overrides how the core handles one.

Decoders are consulted BEFORE the built-in type set and before the encoding.TextUnmarshaler hatch, so a registered decoder wins over both. That ordering is deliberate: overriding is the reason to register one.

Use it when a type is not yours to change — a struct from a third-party package that implements no unmarshaler — or when a type must be parsed differently in configuration than everywhere else in the program. If the type IS yours, implement encoding.TextUnmarshaler instead: the rule then travels with the type rather than with the load call.

type DecoderFunc

type DecoderFunc func(typ reflect.Type, raw string) (any, bool, error)

DecoderFunc adapts a function into a Decoder.

func (DecoderFunc) Decode

func (f DecoderFunc) Decode(typ reflect.Type, raw string) (any, bool, error)

Decode implements Decoder.

type Defaulter

type Defaulter interface {
	Defaults()
}

Defaulter is implemented by any struct in the tree that wants to populate itself before binding.

This is the primary defaults mechanism, preferred over `default:` tags, because it is typed and refactor-safe: a renamed field is a compile error rather than a silently dropped default. It may also compute, which a tag cannot.

type Deriver

type Deriver interface {
	Derive() error
}

Deriver fills fields computed from other fields. It runs after all sources are bound and before validation, so it may read any bound value and its output is validated like everything else.

type Field

type Field struct {
	Path   string `json:"path"`   // "Server.Port" — the Go path, not the key
	Key    string `json:"key"`    // "PORT" — the flat key consulted
	Value  string `json:"value"`  // rendered; masked when Secret and not revealed
	Source string `json:"source"` // "default" | "file:.env" | "environ" | a Source's Name
	Secret bool   `json:"secret"`
}

Field describes one resolved field and, crucially, where its value came from.

Provenance is the capability no other Go config library offers, and it answers the most common configuration support question there is: "why is my port 9001 when I set 2310?" Layered configuration without it is a guessing game that gets worse with every layer added.

type KeyLister

type KeyLister interface {
	// Keys returns every key this source could answer for. Order does not
	// matter; duplicates are harmless.
	Keys() []string
}

KeyLister is implemented by a source that knows its COMPLETE key set, so cfgkit can report a key that matched no field — a typo.

It is optional on purpose, and the omission is the whole design. FromEnviron deliberately does NOT implement it: the process environment holds PATH, HOME, SHELL and sixty more variables that belong to the machine rather than to this application, and a report listing all of them buries the one line that matters. Rather than asking FromEnviron to return false from some CanEnumerate method — a lie a future refactor could get wrong — the type simply lacks the method, and the type system carries that fact permanently.

A source whose keys ARE the application's should implement it: a .env file, a prefixed environment, a map, a secret store that can list its own paths. Four lines buys typo detection for that source.

type Mode

type Mode string

Mode selects one coherent, tested bundle of behaviour rather than exposing many independent booleans.

Why one knob: each independent on/off flag creates a second code path that nobody exercises. Two modes mean two configurations that are actually run — and in a zero-config tool the lenient path is what every new user hits first, so it must be the well-tested one.

const (
	ModeDev  Mode = "dev"
	ModeTest Mode = "test"
	ModeProd Mode = "prod"
)

type Observer

type Observer interface {
	// ObserveResolve fires once per bound field, after its value is decoded.
	//
	// It receives the field with its value UNMASKED, because an audit sink
	// needs the real value. An observer that logs MUST consult Field.Secret
	// itself — cfgkit cannot know whether a given sink is safe.
	ObserveResolve(f Field)
}

Observer is told about every field once it resolves. It cannot change anything — that is what makes it safe to register several.

Use it for auditing (record which secrets were read and from where), metrics (count fields still on their compiled-in defaults), or a startup warning about deprecated keys still in use.

type ObserverFunc

type ObserverFunc func(f Field)

ObserverFunc adapts a function into an Observer.

func (ObserverFunc) ObserveResolve

func (f ObserverFunc) ObserveResolve(field Field)

ObserveResolve implements Observer.

type Option

type Option func(*options)

Option configures a Load.

func DefaultSources

func DefaultSources(extra ...any) Option

DefaultSources configures the conventional chain: the `.env` files for the resolved mode, then the process environment, then any extra sources given.

cfg, res, err := cfgkit.Load[Config](cfgkit.DefaultSources())

is the same as writing this by hand, which most services otherwise do identically:

cfgkit.WithSources(
	cfgkit.FromFiles(".env", ".env.local", ".env.dev", ".env.dev.local"),
	cfgkit.FromEnviron(),
)
cfgkit.WithMode(cfgkit.ModeDev)

WHY THIS IS NOT THE MAGIC THE NON-GOALS REJECT: the chain is a list this function builds and hands to the same WithSources every caller uses. Nothing is hidden at resolution time — Explain still names the exact file or `environ` that supplied each field, and Result.Files reports the chain that was consulted. What a reader loses is seeing the filenames in main.go; what they gain is that the chicken-and-egg below is solved once, correctly, rather than re-derived wrongly in every service.

Extra sources are placed at the HIGHEST precedence, after the environment, because that is where a flag set or a per-run override belongs. A source that must sit lower — a secret store that the environment should be able to override — needs the explicit list; this helper does not try to express every arrangement, only the common one.

A missing file is not an error, which is what keeps zero-config true: the whole chain may be absent and the load still succeeds on compiled-in defaults.

func DefaultSourcesIn

func DefaultSourcesIn(dir string, extra ...any) Option

DefaultSourcesIn is DefaultSources rooted at dir, for a program whose configuration does not live in the working directory — a test fixture directory, or a service in a monorepo run from the repository root.

func Reveal

func Reveal() Option

Reveal allows secret values to appear in Explain output. It is a separate, explicit call so that a report is safe by default: without it, a secret's value is never placed into the output at all.

func WithDecoder

func WithDecoder(d Decoder) Option

WithDecoder registers a decoder. Later registrations are consulted first, so the most recently added decoder wins — matching how a caller expects a late override to behave.

func WithMode

func WithMode(m Mode) Option

WithMode sets the mode explicitly, bypassing the environment lookup.

func WithModeKey

func WithModeKey(key string) Option

WithModeKey changes which environment variable supplies the mode.

func WithObserver

func WithObserver(ob Observer) Option

WithObserver registers an observer. All observers run, in registration order.

func WithSources

func WithSources(sources ...any) Option

WithSources sets the ordered source list. A LATER source overrides an earlier one, regardless of whether it is flat or structured.

Precedence is positional and explicit. There is no implicit ordering and no built-in default chain, because a convenience that hides precedence is the magic this package exists to avoid.

func WithTransform

func WithTransform(fn func(key, value, source string) (string, error)) Option

WithTransform registers a transformer from a plain function.

Transformers chain in registration order: the first registered runs first, and each subsequent one sees the previous output.

func WithTransformer

func WithTransformer(t Transformer) Option

WithTransformer registers a transformer implemented as a type, for a transformer that needs its own state — a decryption client, a cache.

type RequiredError

type RequiredError struct {
	Path  string
	Key   string
	Mode  Mode // the mode that made it required, empty when unconditional
	Empty bool // true when the key resolved but the value was ""
}

RequiredError reports a field that had to resolve and did not.

Missing and Empty are separate because they have different causes and different fixes: a missing key needs a new line in the deployment, while an empty one needs a value filled into a line that already exists. Reporting both as "required" sends an operator looking for a line that is already there.

func (*RequiredError) Error

func (e *RequiredError) Error() string

Error distinguishes the two causes in words, not just in the Empty field: "no source supplied it" versus "resolved to an empty value". They need different fixes, and mode is named when the requirement was conditional so nobody hunts for a rule that does not apply to their environment.

type Result

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

Result records how a configuration was assembled.

func Load

func Load[T any](opts ...Option) (*T, *Result, error)

Load builds a T from the configured sources.

The pipeline is: defaults, then sources in order, then Derive, then Validate. It returns the config, a Result describing where every value came from, and an error joining every problem found — never just the first.

func (*Result) Explain

func (r *Result) Explain(w io.Writer) error

Explain writes an aligned table of every field, its value, and its origin.

Secret values are absent unless the load was made with Reveal, so the default output is safe to paste into an issue.

func (*Result) Fields

func (r *Result) Fields() []Field

Fields returns every resolved field, sorted by Go path so two runs of the same configuration produce identical output and can be diffed.

func (*Result) Files

func (r *Result) Files() []string

Files reports the .env chain DefaultSources consulted, lowest precedence first, including files that did not exist.

It answers the question the chain's convenience creates: with the filenames no longer written in main.go, "which files were even looked at?" would otherwise be unanswerable — and a typo'd filename would look exactly like a file whose values were overridden.

func (*Result) JSON

func (r *Result) JSON() ([]byte, error)

JSON returns the provenance record as JSON, masked by the same rule as Explain.

func (*Result) Mode

func (r *Result) Mode() Mode

Mode reports the mode this configuration was loaded under.

func (*Result) Unknown

func (r *Result) Unknown() []UnknownKey

Unknown returns every key a source supplied that matched no field.

Only sources implementing KeyLister contribute, so FromEnviron never appears here: its key set is the whole machine, and a report listing PATH and HOME would bury the one line that matters.

The finding is advisory. Act on it in whatever way suits the deployment:

if u := res.Unknown(); len(u) > 0 {
	log.Printf("config: %v", u)
}

type Set

type Set struct {
	Name  string
	Value any
}

Set pairs a field name with its value for the group rules. It exists because Go cannot recover a field's name from its value, and an error naming "arg 2" instead of "Database.URL" is not worth printing.

type Source

type Source interface {
	// Name identifies the source in provenance output ("file:.env.local",
	// "environ", "vault"). It appears in error messages and in Explain, so it
	// should name the concrete origin rather than the type.
	Name() string

	// Lookup returns the value for key. found=false means "this source has no
	// opinion", and resolution continues to the next source.
	//
	// An error is NOT a miss: it aborts the whole Load. An unreachable secret
	// store must never be indistinguishable from an unset variable, because
	// that difference is a deploy proceeding with an empty password.
	//
	// COST CONTRACT: Lookup is called once per bound field, so a remote store
	// MUST pre-load or cache. A source that dials the network per key turns a
	// 200-field config into 200 round-trips at boot.
	Lookup(key string) (value string, found bool, err error)
}

Source supplies raw string values for keys. Implementations must be safe for concurrent use and must not mutate process state.

func FlagSource

func FlagSource(name string, typed map[string]string) Source

FlagSource wraps a map of flag names the user actually typed, for flag packages other than the stdlib's.

A contrib module for cobra/pflag builds this map by walking the flag set and keeping only flags whose Changed field is true — pflag's spelling of "the user typed it". Exposing the map rather than an interface keeps the contrib module to a dozen lines and keeps pflag out of this module's dependencies.

func FromEnviron

func FromEnviron() Source

FromEnviron returns a Source backed by the process environment.

func FromFS

func FromFS(fsys fs.FS, paths ...string) Source

FromFS returns a Source backed by .env files inside an fs.FS.

The case this exists for is go:embed: a program can carry its own defaults in the binary and still be overridden by a file or the environment, with no file needing to exist on the target machine at all.

//go:embed defaults.env
var defaults embed.FS

cfgkit.WithSources(
	cfgkit.FromFS(defaults, "defaults.env"),  // compiled in, lowest
	cfgkit.FromFiles(".env"),                 // optional local override
	cfgkit.FromEnviron(),                     // deployment wins
)

Every rule FromFiles follows holds here: later paths win, a missing entry is silent, ${VAR} references resolve and may name a key an earlier file defined, and the whole set is read once at construction.

PATHS ARE fs.FS PATHS, not OS paths — always forward-slash separated and never rooted, even on Windows, because that is what io/fs specifies. A leading "/" or a volume letter will simply not be found.

func FromFiles

func FromFiles(paths ...string) Source

FromFiles returns a Source backed by .env files, parsed by ubgo/dotenv.

Files are consulted in REVERSE order, so a later path in the argument list wins — matching the .env / .env.local / .env.<mode> convention users already know from Vite, Next and Rails.

A missing file is NOT an error. That is what makes zero-config possible: the absence of configuration is a normal, silent, correct outcome, and it mirrors dotenv.Open's own promise.

Files are read once, at construction, so Lookup honours the cost contract.

func FromFlagSet

func FromFlagSet(fs *flag.FlagSet) Source

FromFlagSet returns a Source backed by a parsed stdlib flag set, matching flags to fields through the `flag:"name"` tag.

THE RULE, and the one thing every other library gets wrong: a flag counts only when the user actually typed it. A flag's own default must never enter the configuration.

Why it matters. Declare --port with default 8080, and put PORT=3000 in a .env file. The user types no flag. A reader that asks the flag set for "port" receives 8080 and treats it as a value — so the flag default silently beats the file, and the file is dead for every field that happens to have a flag.

The stdlib offers two walks and only one is correct:

fs.VisitAll — every declared flag, defaults included. WRONG.
fs.Visit    — only the flags the user typed. CORRECT.

This is a long-lived defect in viper: "Default value of Cobra flag overrides the viper env variable" (#671), "BindPFlags functionality does not seem to match documentation" (#375). Koanf avoids it by asking the config object whether another provider already set the key, which needs a back-reference. cfgkit needs neither mechanism, because its defaults already live in Go: a flag never has to supply one, so the rule collapses to "typed flags win, everything else is invisible".

ORDER: a flag set holds nothing until it is parsed. With cobra, parsing happens when the command runs, so Load belongs inside RunE — never in init() or a package-level variable. A Load that runs too early sees an empty flag set, silently ignores every flag, and looks like "flags do not work".

Flags are also opt-in per field: a config with 150 fields must not produce 150 flags, so only a field carrying a `flag:` tag is ever read from here.

func FromMap

func FromMap(m map[string]string) Source

FromMap returns a Source backed by an in-memory map. It is the seam tests use to run without touching the process environment or the filesystem.

func FromPrefixedEnviron

func FromPrefixedEnviron(prefix string) Source

FromPrefixedEnviron is FromEnviron restricted to keys carrying prefix, with the prefix stripped before matching.

Why it exists: one process may host several components whose configurations would otherwise collide on short names like PORT. The prefix namespaces them without every field having to repeat it in a tag.

func SourceFunc

func SourceFunc(name string, fn func(key string) (value string, found bool, err error)) Source

SourceFunc wraps fn as a Source named name.

This is the extension point that makes the catalogue open-ended: any flat backend — Vault, SSM, Consul, a database table — is a closure, and its dependency stays in the caller rather than in cfgkit.

type SourceError

type SourceError struct {
	Source string
	Key    string
	Err    error
}

SourceError reports that a source itself failed.

This is never a miss. An unreachable secret store must not be indistinguishable from an unset variable, because that difference is a deploy proceeding with an empty password.

func (*SourceError) Error

func (e *SourceError) Error() string

Error renders "source NAME failed for KEY: cause", naming both the source and the key. A load drawing on six sources otherwise leaves the reader guessing which one was unreachable.

func (*SourceError) Unwrap

func (e *SourceError) Unwrap() error

Unwrap exposes the transport failure, so a caller can match on its own backend's error types through errors.As without parsing this message.

type StructuredSource

type StructuredSource interface {
	Name() string

	// Apply merges into dst, which already holds defaults and every earlier
	// source's values. Implementations MUST leave absent fields untouched —
	// encoding/json does this natively, which is why FromJSON is four lines.
	// Apply runs once per Load, so it carries no per-key cost concern.
	Apply(dst any) error
}

StructuredSource merges nested data onto the destination struct. It exists because nested data has a shape that flat keys cannot express.

func FromJSON

func FromJSON(b []byte) StructuredSource

FromJSON returns a StructuredSource backed by a JSON object.

This is the seam Pkl uses: `pkl eval -f json` at BUILD time, go:embed the result, and hand the bytes here. The pkl binary never has to exist at run time, which keeps a JVM out of the production image and turns a config error into a build failure rather than a container-start panic.

encoding/json does the whole job: it fills only the fields present in the document and leaves everything else untouched, which is exactly the overlay semantics a layered loader needs. Note that nested structs MERGE field by field while slices and maps REPLACE wholesale — standard json behaviour, and almost always what a reader expects, but worth knowing.

func FromStruct

func FromStruct(name string, v any) StructuredSource

FromStruct returns a StructuredSource backed by an already-populated Go struct, merged onto the destination through the `json:` tags both share.

WHEN THIS IS THE RIGHT TOOL, and when it is not. `Defaults()` is how a struct supplies its own baseline: it is typed, refactor-safe, and a renamed field is a compile error. This is for the different case where the values come from somewhere the source list cannot reach — a config service with its own client, a test fixture, a struct assembled by a caller's own logic — and need to enter the chain at a chosen precedence rather than as a baseline.

fetched := myConfigService.Fetch(ctx)          // your client, your types
cfgkit.WithSources(
	cfgkit.FromStruct("service", fetched),      // slots in wherever you put it
	cfgkit.FromEnviron(),                       // still wins
)

It round-trips through encoding/json, which is what makes the overlay semantics identical to FromJSON: a field the value does not mention is left untouched, so an earlier source's value is not erased by silence. The cost of that choice is that ONLY json-tagged fields travel, and a zero value is indistinguishable from an unset one unless the field is a pointer or carries `omitempty` — the same rule every JSON-shaped source here follows.

func StructuredFunc

func StructuredFunc(name string, fn func(dst any) error) StructuredSource

StructuredFunc wraps fn as a StructuredSource named name.

Because fn receives the destination struct directly, the parser for a format lives in the caller: yaml.Unmarshal(b, dst) makes YAML work without cfgkit ever importing a YAML package. The library never has to add a format and never has to refuse one.

type Transformer

type Transformer interface {
	// Transform returns the value to use in place of value.
	//
	// An error aborts the whole Load. Return the value unchanged rather than an
	// error when the transformer simply does not apply.
	Transform(key, value, source string) (string, error)
}

Transformer rewrites a raw value after a source supplied it and before it is decoded.

Transformers are CHAINED in registration order, each seeing the previous one's output, so several may compose — decrypt, then expand a template, then trim. A transformer that does not care about a value returns it unchanged.

It receives the key and the source name as well as the value, so a transformer can act on one origin only: decrypt values from Vault, leave the same key alone when it came from a local .env file.

Use it when values arrive in a form the field's type cannot parse but a mechanical step can fix: ciphertext, base64, a value wrapped in quotes by a platform, a legacy format you are migrating away from.

type TransformerFunc

type TransformerFunc func(key, value, source string) (string, error)

TransformerFunc adapts a function into a Transformer.

func (TransformerFunc) Transform

func (f TransformerFunc) Transform(key, value, source string) (string, error)

Transform implements Transformer.

type UnknownKey

type UnknownKey struct {
	Key    string `json:"key"`    // "DATABAS_URL"
	Source string `json:"source"` // the source that supplied it
}

UnknownKey is a key a source supplied that matched no field — almost always a typo, and otherwise a key meant for a different consumer of the same file.

It is REPORTED, never fatal. One .env file legitimately serves several audiences: sync_go's .env.prod carries GITHUB_SECRET_* keys for its deployment pipeline alongside the application's own configuration, and those keys are unknown to the binary by design. A loader that refused to start would break exactly the pattern dotenvctl's --prefix selection exists to serve.

func (UnknownKey) String

func (u UnknownKey) String() string

String renders one finding for a log line.

type UnreachableFieldError

type UnreachableFieldError struct {
	Path string // "Config" — the embedded field
	Type string // "*internalConfig"
}

UnreachableFieldError reports a struct shape whose fields no source could ever fill, so that it fails loudly instead of binding nothing.

The only shape that produces it is an embedded POINTER to an unexported type carrying config tags. reflect refuses to set such a pointer, so cfgkit cannot allocate it and every field beneath it is unreachable. Embedding the type by VALUE works and is the fix; exporting the type also works.

func (*UnreachableFieldError) Error

func (e *UnreachableFieldError) Error() string

Error names the field, its type, and both fixes — embed by value, or export the type. A shape error that does not say how to reshape it just relocates the puzzle.

type UnreachableHookError

type UnreachableHookError struct {
	Path string // "Config" — the embedded field
	Type string // "frameworkConfig"
	Hook string // "Validate"
}

UnreachableHookError reports a Defaults, Derive or Validate method that can never run, so that it fails loudly instead of silently doing nothing.

Go's reflect refuses to produce an interface value for anything reached through an unexported field, and an embedded unexported struct is exactly that. Its FIELDS still bind — reflect can set them, and encoding/json binds them too — but its methods are unreachable. A framework's Validate is what enforces its invariants, so a silently dead one is worse than a load failure.

The fix is to export the embedded type. Across packages it must be exported anyway, so this only ever fires within one package.

func (*UnreachableHookError) Error

func (e *UnreachableHookError) Error() string

Error names the field, its type, the dead hook, and the fix. It says which hook so a reader is not left checking all three.

type ValidationError

type ValidationError struct {
	Path string
	Rule string
	Msg  string
}

ValidationError reports a rule that failed. Rule names the check so a reader can find it in the code without matching on message text.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error renders "Path: message (rule)". The rule name is included so a reader can grep for the check itself rather than matching on message text, which is the thing most likely to be reworded.

type Validator

type Validator interface {
	Validate() error
}

Validator reports every problem with a struct, joined.

Returning early on the first failure would make a misconfigured deploy a guessing game of one fix per restart. Implementations should use errors.Join.

type Watcher

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

Watcher holds the current configuration behind an atomic pointer, so any number of goroutines may read it while a reload builds the next one.

It is a thin layer over Load and adds no way to configure that Load does not have. What it adds is a safe moment to swap.

func NewWatcher

func NewWatcher[T any](build func() []Option) (*Watcher[T], error)

NewWatcher loads the configuration and returns a Watcher holding it.

build is called once now and again on every Reload. It must return a fresh option list each time — construct the sources inside it:

w, err := cfgkit.NewWatcher(func() []cfgkit.Option {
	return []cfgkit.Option{cfgkit.DefaultSources()}
})

An error from the FIRST load is returned and no Watcher is produced: a process must not start on a configuration that does not load. Errors from later reloads are different — see Reload.

func (*Watcher[T]) Current

func (w *Watcher[T]) Current() *T

Current returns the newest complete configuration.

The returned value is effectively immutable: a later Reload publishes a NEW one and never edits this. A caller may therefore hold it for the length of a request, read ten fields from it, and be certain all ten came from the same generation — with no lock, and with no chance of a torn read.

THE RULE THAT MATTERS, and the one no library can enforce: a long-lived component must hold the WATCHER and call Current when it needs a value. A component handed *T at startup has taken a copy, and no reload will ever reach it. That is danger 6, and it is a property of the program rather than of this package.

// wrong: captured once, never updated
func NewHandler(cfg *Config) *Handler { return &Handler{cfg: cfg} }

// right: reads the current generation per request
func NewHandler(w *cfgkit.Watcher[Config]) *Handler { return &Handler{w: w} }

func (*Watcher[T]) Generation

func (w *Watcher[T]) Generation() uint64

Generation reports how many configurations have been published, starting at 1 for the one loaded by NewWatcher.

It is the answer to "did my reload actually take effect": a caller that sees the same number after a Reload knows the reload failed, without having to diff the configuration.

func (*Watcher[T]) Reload

func (w *Watcher[T]) Reload() error

Reload re-reads every source and builds a NEW configuration, running the full pipeline — defaults, sources, bind, derive, validate — BEFORE publishing it.

ON ANY ERROR THE PREVIOUS CONFIGURATION STAYS IN SERVICE and the error is returned. A bad edit to a .env file therefore cannot take the process down, which is the difference between this and a watcher that assigns first and discovers the problem later. It also means a caller can log the error and keep running, rather than having to decide what to do with a half-applied configuration.

The package RELOADS; it does not WATCH. The trigger belongs to the caller: SIGHUP, a timer, an admin route, or fsnotify if they want a file watch. All but the last are stdlib, and the last stays the caller's dependency rather than becoming everyone's.

func (*Watcher[T]) Result

func (w *Watcher[T]) Result() *Result

Result returns the provenance of the configuration Current would return.

func (*Watcher[T]) Snapshot

func (w *Watcher[T]) Snapshot() (*T, *Result)

Snapshot returns the configuration and its provenance from the SAME generation.

Use it wherever both are needed — an admin endpoint that prints a value and explains where it came from. Calling Current and Result separately can straddle a reload and describe a value with the wrong origin, which is the torn read this type exists to prevent, reintroduced by the caller.

Directories

Path Synopsis
Package cfgkittest provides the harness every cfgkit adapter is tested with.
Package cfgkittest provides the harness every cfgkit adapter is tested with.
contrib
cli-cobra module
Package mock holds ONE canonical configuration, expressed in every format cfgkit can read, together with the struct that binds it and the values it must produce.
Package mock holds ONE canonical configuration, expressed in every format cfgkit can read, together with the struct that binds it and the values it must produce.

Jump to

Keyboard shortcuts

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