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 ¶
- func AtLeastOneOf(fields ...Set) error
- func Check[T any](opts ...Option) error
- func Document[T any](w io.Writer, opts ...Option) error
- func Matches(field, v string, re *regexp.Regexp) error
- func MutuallyExclusive(fields ...Set) error
- func NotEmpty(field string, v string) error
- func NotWeakSecret(current Mode, field, v string, known ...string) error
- func OneOf[T comparable](field string, v T, allowed ...T) error
- func Range[T int | int8 | int16 | int32 | int64 | float32 | float64](field string, v, lo, hi T) error
- func Required(field string, v any) error
- func RequiredIn(mode, current Mode, field string, v any) error
- func RequiredWhen(field string, present bool, condition string, holds bool) error
- type DecodeError
- type Decoder
- type DecoderFunc
- type Defaulter
- type Deriver
- type Field
- type KeyLister
- type Mode
- type Observer
- type ObserverFunc
- type Option
- func DefaultSources(extra ...any) Option
- func DefaultSourcesIn(dir string, extra ...any) Option
- func Reveal() Option
- func WithDecoder(d Decoder) Option
- func WithMode(m Mode) Option
- func WithModeKey(key string) Option
- func WithObserver(ob Observer) Option
- func WithSources(sources ...any) Option
- func WithTransform(fn func(key, value, source string) (string, error)) Option
- func WithTransformer(t Transformer) Option
- type RequiredError
- type Result
- type Set
- type Source
- func FlagSource(name string, typed map[string]string) Source
- func FromEnviron() Source
- func FromFS(fsys fs.FS, paths ...string) Source
- func FromFiles(paths ...string) Source
- func FromFlagSet(fs *flag.FlagSet) Source
- func FromMap(m map[string]string) Source
- func FromPrefixedEnviron(prefix string) Source
- func SourceFunc(name string, fn func(key string) (value string, found bool, err error)) Source
- type SourceError
- type StructuredSource
- type Transformer
- type TransformerFunc
- type UnknownKey
- type UnreachableFieldError
- type UnreachableHookError
- type ValidationError
- type Validator
- type Watcher
Examples ¶
- Package (Check)
- Package (ConditionalRule)
- Package (CustomSource)
- Package (DefaultSources)
- Package (Derive)
- Package (Document)
- Package (Explain)
- Package (FileValue)
- Package (FlagDefaultNeverWins)
- Package (FormerKeyName)
- Package (MapFields)
- Package (MapValuesMayContainColons)
- Package (ModeStrictness)
- Package (OptionalSection)
- Package (OptionalSectionInit)
- Package (Precedence)
- Package (RequiredVsNotEmpty)
- Package (SecretsAreAbsent)
- Package (SlicesAndDurations)
- Package (SourceFailureAborts)
- Package (StructuredAndFlat)
- Package (UnknownKeys)
- Package (UnknownKeysIgnoresEnviron)
- Package (Watcher)
- Package (ZeroInput)
- Package (ZeroValuesOverrideDefaults)
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AtLeastOneOf ¶
AtLeastOneOf reports an error when none of the named fields is set.
func Check ¶
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 ¶
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 MutuallyExclusive ¶
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 ¶
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 ¶
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 RequiredIn ¶
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 ¶
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 ¶
DecoderFunc adapts a function into a 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.
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 ¶
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 ¶
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 ¶
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 WithModeKey ¶
WithModeKey changes which environment variable supplies the mode.
func WithObserver ¶
WithObserver registers an observer. All observers run, in registration order.
func WithSources ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
JSON returns the provenance record as JSON, masked by the same rule as Explain.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
TransformerFunc adapts a function into a 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Result returns the provenance of the configuration Current would return.
func (*Watcher[T]) Snapshot ¶
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.
Source Files
¶
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. |