Documentation
¶
Overview ¶
Package confmaker centralizes explicit configuration of an application's packages, modules and domains. Each package declares a struct with env tags; the application registers its configs with instance names, loads them together and passes typed values to their consumers. Packages do not need to read ENV themselves.
Import the core library:
import "github.com/uchaloop/confmaker"
A Loader is the composition point for multiple configurations, including multiple instances of the same type:
loader := confmaker.MakeLoader()
primary := loader.Register[store.Config]("postgres")
replica := loader.Register[store.Config]("replica")
if err := loader.Load(); err != nil {
return err
}
primaryConfig, err := primary.Value()
if err != nil {
return err
}
replicaConfig, err := replica.Value()
if err != nil {
return err
}
// Pass primaryConfig and replicaConfig to their consumers.
Load is a convenience for loading one configuration. The separate module github.com/uchaloop/confx adapts the same loading rules to Uber Fx. This package reads ENV only: it does not read dotenv files, contact secret stores or start application services. Manifest generation and exports are optional.
Overview ¶
A config is a struct with env tags and, optionally, two methods:
type Config struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
Password secret.Secret `env:"PASSWORD"`
}
func (c *Config) SetDefaults() { c.Timeout = 30 * time.Second }
func (c Config) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive")
}
return nil
}
With instance name "store", it reads STORE_HOST, STORE_TIMEOUT and STORE_PASSWORD. The tags are plain strings, so the config type need not import confmaker. This example imports github.com/uchaloop/secret/v2 for its secret field.
Declaring configurations ¶
A field that names a variable in its env tag is read from that variable. A struct field without an env tag is not read itself: its fields are, and those with env tags become variables of the config, under the prefix extended by the field's envPrefix tag, if any. Untagged fields that contain no configuration are ignored; unsupported pointer or collection nesting of config fields is rejected. env:"-" excludes a field and everything nested in it.
type Config struct {
Host string `env:"HOST"`
Pool PoolConfig `envPrefix:"POOL_"`
}
STORE_HOST
STORE_POOL_MAX_CONNS
A nested config must be held by value. This gives the application a fixed set of fields independent of pointer allocation or collection contents. envPrefix is written into names as it stands, with or without a trailing underscore.
A field may be a string, a bool, any sized integer or float, a time.Duration, a type implementing encoding.TextUnmarshaler, a pointer to one of those, or a slice or map of them. A TextUnmarshaler decodes its own text and reports its own errors; for a manifest or dump it also needs encoding.TextMarshaler. complex, uintptr and []byte are refused. A slice splits on envSeparator ("," by default). A map splits entries the same way and each key from its value on envKeyValSeparator (":" by default). The separator tags apply only to a slice or map read in this plain syntax, not to a scalar, a type with its own text form or a JSON field:
STORE_BROKERS=a:9092,b:9092 STORE_LABELS=env:prod,team:core
A duplicate map key is an error, and a key or value padded with whitespace is reported rather than trimmed.
A set variable replaces its field. An empty variable is read like any other text: a string becomes "", a slice or map becomes empty, a TextUnmarshaler receives empty text, a number, bool or duration fails to parse, and a pointer reads its element the same way.
Each field's variable is read under these tag options:
- required: the variable must be set; empty text is allowed if its type can parse it;
- notEmpty: the variable must be set and its text must be non-empty. It checks the text, not the result: JSON [] is non-empty text for an empty slice. Limits on what a value holds belong in Validate.
A default does not satisfy either option: they concern what the deployment supplies.
Pointer-only type cycles are rejected as declaration errors. Pointers to scalar values remain supported, including multiple levels. Untagged nested configs must be structs held by value; hiding them behind pointers or collections at any depth is rejected. Plain ENV maps cannot use pointer keys, whose address identity would bypass duplicate-key checks. Parsed keys must equal themselves: NaN keys, including custom keys containing NaN, are rejected. NaN values remain valid. Recursive JSON structs remain valid.
Declarations are checked when a config is registered, before any default or variable is read, and a mistake is reported by Loader.Load, Loader.Manifest or Manifest: envDefault (defaults belong in SetDefaults), an unknown option or options without a name, two fields on one variable, envPrefix on anything but a struct nested by value, a secret in a slice, array or map, a config nested through a pointer or collection, an unreadable type, an empty separator or one on a field that is not split, and a variable name no environment can hold (with = or NUL). Secrets are recognised behind any number of pointers.
Names and prefixes ¶
Every config has an instance name. It gives the default prefix - "read-replica" reads READ_REPLICA_* - and labels the config in errors. It comes from the required first argument of Loader.Register, Load or Manifest:
loader.Register[Config]("store") // STORE_*
loader.Register[Config]("replica") // REPLICA_*
loader.Register[Config]("main", confmaker.WithPrefix("MAIN_DB_")) // MAIN_DB_*
Names hold lowercase letters, digits and _ - ., and do not start or end with a separator. An empty name is invalid, even with WithPrefix. Two registrations may not share a name, a prefix, or a variable.
Options come in two kinds: a ConfigOption configures one config, an EnvOption configures a whole load. Passing one where the other is expected does not compile. Load takes both.
Defaults and validation ¶
Loading a config runs these steps:
- SetDefaults, if the config has it, sets defaults in code.
- Each variable that is set replaces its field; an unset variable leaves the default.
- Validate, if the config has it, checks the result. It runs only when every variable of the config parsed.
SetDefaults must be deterministic and free of side effects: loading calls it once per config, and each manifest or export call evaluates it again on a fresh instance. Only the top-level config's methods are called.
JSON values ¶
envFormat:"json" reads one variable as JSON, with encoding/json/v2, into a struct, slice, array or map, or a pointer directly to one of these shapes. Supported members inside that JSON value may themselves be nested:
type Config struct {
Endpoints []Endpoint `env:"ENDPOINTS" envFormat:"json"`
}
STORE_ENDPOINTS=[{"url":"http://a:9000","timeout":"30s"}]
The value is decoded into the field's type:
- A set variable replaces the whole field; nothing is merged with the default. [{"url":"http://a"}] leaves timeout zero even when the default had one.
- Member names match exactly, and duplicate members are errors. Unknown members are rejected unless collected by an inline fallback map. A JSON tag with case:ignore enables case-insensitive matching for that field.
- Only json:"-" ignores a field; json:"-," and json:"-,omitempty" name it "-".
- null clears a pointer, slice or map and is an error elsewhere. [] and {} are empty collections. An empty variable is an error.
- A time.Duration is a string such as "30s", in collections too.
- A type with UnmarshalJSON or UnmarshalText reads its own form, and is as strict as it decides to be.
Refused at registration: envFormat on a scalar, an unknown format, envFormat with a separator tag, interface values, []byte and byte arrays, map keys other than strings, integers or text types, any secret type inside the value, ignored fields included (such a secret is refused, not masked), and a struct json/v2 rejects as an object - two fields on one JSON name, for example. The last check decodes {} into each struct type, which runs no method of the config.
An error names the variable and the place inside the value:
config "store": variable "STORE_ENDPOINTS": JSON at "/0/timeout": time: invalid duration "soon"
A manifest renders a JSON default with sorted map keys and nil as null. Whether it loads back to an equal value depends on the type: custom marshalers decide their own form, and omitempty drops an empty slice or map, which then loads back as nil. omitzero keeps an empty non-nil collection.
Loading and lifecycle ¶
Loader.Load runs in this order:
- Invalid options and registrations are reported; the other configs still load.
- Conflicting registrations - two on one name, prefix or variable - end loading here, before any SetDefaults runs or anything is printed.
- One snapshot of the environment is taken.
- SetDefaults runs for each config.
- The dump is written, when WithDump asks for one. A dump that fails is reported and does not stop the checks.
- Variables under a registered prefix that no field reads are reported.
- The environment is applied and Validate runs, for each config.
Problems are joined into one error. Config registrations are processed in a deterministic order rather than the order of Register calls; unknown variables are sorted by name. Errors from different stages retain their stage order:
unknown configuration variable "STORE_HSOT" (did you mean "STORE_HOST"?) config "store": required variable "STORE_HOST" is not set
Values are handed out only when the whole set loaded; see Handle.Value. Load runs once. A Loader is safe for concurrent use and holds no lock while SetDefaults, Validate, unmarshalers or the dump writer run; those may call Register on that Loader or Value on its handles, but must not call its Load.
Environment and testing ¶
Load reads one snapshot of the environment, shared by loading, the check for unknown variables and the dump. By default it is the process environment; WithEnv replaces it with a map, which suits tests:
cfg, err := confmaker.Load[Config]("store", confmaker.WithEnv(map[string]string{
"STORE_HOST": "localhost",
}))
confmaker reads variables only; reading a file into such a map is left to the caller. Passing nil to WithEnv means an empty environment, not a fallback to the process. WithEnv copies the map when the option is created. AllowUnknown exempts prefixes from the check for unknown variables, for an environment shared with other programs.
Structured errors ¶
ConfigError exposes an ErrorKind, InstanceName, VariableName and FieldPath. Context is empty when unavailable. errors.As finds the first structured problem; ConfigErrors walks wrappers and joins to collect all problems in tree order. Each ConfigError counts as one problem, including a Validate error whose cause is itself a join. Its original cause remains reachable through errors.Is and errors.As. Returned pointers should be treated as read-only.
Standard scalar parsers retain their original causes without changing the human-readable message. Bool, integer and float errors expose strconv.NumError and its syntax or range cause. Duration errors retain the time parser's cause.
Secret parse causes are discarded rather than exposed through Unwrap. Context fields do not contain ENV values, but ordinary error messages and user Validate errors may contain them. Writer and lifecycle errors remain separate; always handle the original error even when ConfigErrors returns no problems.
Manifest, dump and secrets ¶
Loader.Manifest describes a snapshot of all registrations as []ConfigManifest, ordered by instance name, with variables in declaration order. It checks invalid registrations and conflicts before invoking config methods. Any declaration, conflict or rendering error returns a nil result. Load options, including WithEnv and WithDump, are ignored.
configs, err := loader.Manifest()
It can run before, during or after Load, without reading ENV, calling Validate, writing a dump or changing loader state. Each call evaluates defaults on fresh instances and never reads loaded values. Registrations completed after the snapshot are included on the next call. If called concurrently with Load or another Manifest, user-supplied defaults and marshalers must support concurrent calls. No loader lock is held while those methods run.
Loader.WriteEnvExample writes a reference .env.example to an io.Writer using that same manifest. The envDescription tag supplies Variable.Description and comments in the template. Safe non-zero scalar defaults become assignments; secrets, complex values and defaults requiring quoting become commented placeholders. Zero defaults are indistinguishable from absent defaults. Names unsuitable for dotenv assignments are preserved in quoted comments. This does not impose additional restrictions on names accepted by Load. The caller owns file handling; no dotenv file is read automatically. Manifest errors write nothing; writer errors may leave partial output.
Loader.WriteManifestJSON exports a versioned JSON document with fixed camelCase keys, preserving all variable metadata except secret defaults. Loader.WriteManifestMarkdown exports sections and tables for documentation, escaping text and distinguishing required from required non-empty variables. Both use the same Manifest lifecycle and ordering, finish preparation before writing and leave file handling to the caller. Writer failures can leave partial output. JSON's hasDefault retains the non-zero semantics described below.
Manifest and WithDump describe the same variables for different purposes:
- Manifest returns metadata for generating a .env.example, a config map or a table. It does not read the environment, but it calls SetDefaults and renders each default with MarshalText or the field's plain syntax.
- WithDump prints, while loading, each variable's environment value or default and its source. It is an input report, not proof that loading succeeded. Control characters are escaped.
Manifest and all exporters are strict: if any default cannot be rendered, the whole description fails. This includes nil pointers anywhere in a plain collection element or a default that its separators cannot represent. A commented .env.example placeholder does not bypass that check. Ordinary loading without WithDump does not require defaults to be rendered. Variable.HasDefault reports a non-zero default and cannot tell an explicit false, 0 or "" from no default. Manifest values are ENV text; a generator escapes them for shell or YAML itself.
A field of a secret type (github.com/uchaloop/secret/v2) is never printed: not in the dump, the manifest or a parse error. Secrets are recognised by type only. A password written into a plain string, such as postgres://user:password@host/db, is printed like any other text; keep it in its own secret field.
Index ¶
- Variables
- func Load[T any](name string, opts ...LoadOption) (T, error)
- type ConfigError
- type ConfigManifest
- type ConfigOption
- type EnvOption
- type ErrorKind
- type Handle
- type LoadOption
- type Loader
- func (l *Loader) Load() error
- func (l *Loader) Manifest() ([]ConfigManifest, error)
- func (l *Loader) Register[T any](name string, opts ...ConfigOption) *Handle[T]
- func (l *Loader) WriteEnvExample(writer io.Writer) error
- func (l *Loader) WriteManifestJSON(writer io.Writer) error
- func (l *Loader) WriteManifestMarkdown(writer io.Writer) error
- type Variable
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotLoaded is returned by [Handle.Value] until [Loader.Load] has finished. ErrNotLoaded = errors.New("configuration is not loaded; call Load first") // ErrRegisteredAfterLoad is returned by [Handle.Value] for a config added // with [Loader.Register] once Load had started. ErrRegisteredAfterLoad = errors.New("config registered after Load started; register every config before loading") )
Functions ¶
func Load ¶
func Load[T any](name string, opts ...LoadOption) (T, error)
Load loads one config of type T, as a Loader with a single registration would. The required name determines the default ENV prefix. Options include ConfigOption (WithPrefix) and EnvOption (WithEnv, WithDump, AllowUnknown). Unknown variables are looked for under T's own prefix only. On error it returns the zero T.
cfg, err := confmaker.Load[store.Config]("store")
cfg, err := confmaker.Load[store.Config]("replica", confmaker.WithEnv(vars))
Example ¶
Load reads one config. WithEnv stands in for the process environment here, so the example runs the same everywhere; an application calls Load without it.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
cfg, err := confmaker.Load[StoreConfig]("store", confmaker.WithEnv(map[string]string{
"STORE_HOST": "db:5432",
}))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(cfg.Host, cfg.Timeout)
}
Output: db:5432 30s
Example (Errors) ¶
One error reports every problem, with a suggestion for a misspelled variable. Validate would refuse the zero timeout, but it does not run while a variable of the config is missing: its report comes once the host is fixed.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
_, err := confmaker.Load[StoreConfig]("store", confmaker.WithEnv(map[string]string{
"STORE_HSOT": "db:5432",
"STORE_TIMEOUT": "0s",
}))
fmt.Println(err)
}
Output: unknown configuration variable "STORE_HSOT" (did you mean "STORE_HOST"?) config "store": required variable "STORE_HOST" is not set
Types ¶
type ConfigError ¶ added in v0.8.0
type ConfigError struct {
Kind ErrorKind
InstanceName string
VariableName string
FieldPath string
// contains filtered or unexported fields
}
ConfigError describes one configuration problem. Context fields are empty when unavailable; conflicts involving multiple instances have no single owner. No field holds an ENV value. Error messages and causes for ordinary fields and user validation may contain values; secret parse causes are discarded. Use errors.As to find the first problem or ConfigErrors to collect all of them.
func ConfigErrors ¶ added in v0.8.0
func ConfigErrors(err error) []*ConfigError
ConfigErrors collects structured problems in depth-first, left-to-right order through both single wrappers and joined errors. It returns nil for nil or an error without structured problems. A ConfigError is one problem: its cause is not expanded, so Validate's own joined errors retain their context. Repeated occurrences are preserved. Returned pointers refer to the errors in the tree and should be treated as read-only, like the load result itself.
Example ¶
type config struct {
URL string `env:"URL,notEmpty"`
}
_, err := Load[config]("api", WithEnv(map[string]string{}))
for _, problem := range ConfigErrors(err) {
fmt.Println(problem.Kind, problem.InstanceName, problem.VariableName, problem.FieldPath)
}
Output: required api API_URL URL
func (*ConfigError) Error ¶ added in v0.8.0
func (e *ConfigError) Error() string
Error returns the problem's message. The surrounding load error adds the instance label when applicable.
func (*ConfigError) Unwrap ¶ added in v0.8.0
func (e *ConfigError) Unwrap() error
Unwrap preserves the original cause, except for secret parse errors, whose unsafe cause is replaced with a value-free message.
type ConfigManifest ¶ added in v0.8.0
type ConfigManifest struct {
// InstanceName is the name passed to Loader.Register.
InstanceName string
// Prefix is the resolved ENV prefix, including any WithPrefix override.
Prefix string
// Variables lists fields in declaration order. Secret defaults are omitted.
Variables []Variable
}
ConfigManifest describes one registered configuration and its environment variables. It contains defaults, not values read from the environment.
type ConfigOption ¶ added in v0.6.2
type ConfigOption interface {
LoadOption
// contains filtered or unexported methods
}
ConfigOption configures one config with WithPrefix. Loader.Register, Manifest and Load take it.
func WithPrefix ¶ added in v0.6.2
func WithPrefix(prefix string) ConfigOption
WithPrefix overrides the environment prefix without changing the instance name. It must be non-empty, contain only upper-case letters, digits or underscores, and end with an underscore, for example "REPLICA_POSTGRES_".
Example ¶
The instance name stays primary while WithPrefix selects DATABASE_* variables.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
cfg, err := confmaker.Load[StoreConfig]("primary",
confmaker.WithPrefix("DATABASE_"),
confmaker.WithEnv(map[string]string{"DATABASE_HOST": "db:5432"}),
)
if err != nil {
fmt.Println(err)
return
}
variables, err := confmaker.Manifest[StoreConfig]("primary", confmaker.WithPrefix("DATABASE_"))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(cfg.Host, variables[0].Name)
}
Output: db:5432 DATABASE_HOST
type EnvOption ¶ added in v0.6.2
type EnvOption interface {
LoadOption
// contains filtered or unexported methods
}
EnvOption configures a whole load: WithEnv, WithDump and AllowUnknown. MakeLoader and Load take it.
func AllowUnknown ¶ added in v0.6.2
AllowUnknown exempts variables starting with the given prefixes from the check for unknown variables, for an environment shared with other programs. The prefixes are copied. An empty prefix is ignored: it would match every variable and turn the check off.
func WithDump ¶ added in v0.6.2
WithDump writes a table to w while loading: each variable the configs declare, its type, its environment value or rendered default, and where the value comes from. It is written even when loading fails, except for conflicting registrations, so it is not proof that loading succeeded. Secrets are shown as set or unset, never printed.
Example ¶
WithDump prints each variable, its value and where it came from.
package main
import (
"fmt"
"os"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
_, err := confmaker.Load[StoreConfig]("store", confmaker.WithDump(os.Stdout), confmaker.WithEnv(map[string]string{"STORE_HOST": "db:5432"}))
fmt.Println(err)
}
Output: INSTANCE VARIABLE TYPE VALUE SOURCE store STORE_HOST string db:5432 env store STORE_TIMEOUT time.Duration 30s default <nil>
func WithEnv ¶ added in v0.6.2
WithEnv makes a load read vars instead of the process environment.
- vars is the complete environment: nothing is added from the process.
- nil is an empty environment, not the process environment.
- The map is copied when WithEnv is called; later changes to it are not seen.
- WithEnv may be given once per load; a second one is reported by Loader.Load.
Without WithEnv, a load takes a snapshot of the process environment when it starts.
type ErrorKind ¶ added in v0.8.0
type ErrorKind string
ErrorKind identifies the stage or condition that produced a ConfigError. The zero value does not identify a category.
const ( // ErrorDeclaration identifies invalid config types, tags, names or options. ErrorDeclaration ErrorKind = "declaration" // ErrorConflict identifies competing names, prefixes or variables. ErrorConflict ErrorKind = "conflict" // ErrorRequired identifies an absent required variable. ErrorRequired ErrorKind = "required" // ErrorEmpty identifies an empty variable declared notEmpty. ErrorEmpty ErrorKind = "empty" // ErrorParse identifies a value that could not be parsed. ErrorParse ErrorKind = "parse" // ErrorUnknownVariable identifies an undeclared variable under a config prefix. ErrorUnknownVariable ErrorKind = "unknown_variable" // ErrorValidation identifies an error returned by Validate. ErrorValidation ErrorKind = "validation" // ErrorDefaultRender identifies a default that could not be rendered. ErrorDefaultRender ErrorKind = "default_render" )
type Handle ¶ added in v0.6.2
type Handle[T any] struct { // contains filtered or unexported fields }
Handle is a config registered with Loader.Register, read with Handle.Value. The zero Handle belongs to no loader, and its Value returns an error.
func (*Handle[T]) Value ¶ added in v0.6.2
Value returns the loaded config, or an error:
- ErrNotLoaded until Loader.Load has finished;
- the error Load returned, when any config of the set failed - even if this config loaded on its own;
- ErrRegisteredAfterLoad for a config added once Load had started.
The struct is returned by value, but maps, slices and pointers inside it are shared by every caller. Treat a loaded config as read-only.
type LoadOption ¶ added in v0.6.2
type LoadOption interface {
// contains filtered or unexported methods
}
LoadOption is an option Load takes: every ConfigOption and every EnvOption.
type Loader ¶ added in v0.6.2
type Loader struct {
// contains filtered or unexported fields
}
Loader loads a set of configs together: configs are registered with Loader.Register, loaded with Loader.Load, and read with Handle.Value.
A Loader is safe for concurrent use and holds no lock while user code runs: SetDefaults, Validate, unmarshalers and the dump writer may call Register on that Loader or Value on its handles. They must not call Load recursively: it would wait for the load already in progress.
Example ¶
A Loader loads several configs, here two instances of one type, and reports their problems together.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
loader := confmaker.MakeLoader(confmaker.WithEnv(map[string]string{
"STORE_HOST": "db:5432",
"REPLICA_HOST": "replica:5432",
}))
primary := loader.Register[StoreConfig]("store") // STORE_*
replica := loader.Register[StoreConfig]("replica") // REPLICA_*
if err := loader.Load(); err != nil {
fmt.Println(err)
return
}
primaryCfg, _ := primary.Value()
replicaCfg, _ := replica.Value()
fmt.Println(primaryCfg.Host, replicaCfg.Host)
}
Output: db:5432 replica:5432
func MakeLoader ¶ added in v0.6.2
MakeLoader returns an empty Loader. opts configure the whole load: WithEnv, WithDump, AllowUnknown. A nil option is reported by Loader.Load.
func (*Loader) Load ¶ added in v0.6.2
Load loads every registered config and returns every problem as one error: invalid options and registrations, conflicts between registrations, unknown variables, variables that did not parse, and the Validate errors of every config whose variables parsed. The steps are listed in the package documentation.
Load runs once. Calling it again returns the same error, or nil, without reading the environment again; a call made while another goroutine loads waits for that result. If user code panics during the load, the panic propagates from the Load that ran it, and later calls return an error.
func (*Loader) Manifest ¶ added in v0.8.0
func (l *Loader) Manifest() ([]ConfigManifest, error)
Manifest describes a snapshot of all registrations, ordered by instance name. Each config's variables retain declaration order. Invalid registrations and conflicts are reported before any config methods run. Rendering errors are collected across configs; any error returns a nil result, never a partial list.
Manifest does not read ENV, call Validate, write a dump or change loader state. EnvOption settings, including their errors, apply only to Load and are ignored here. Each call runs SetDefaults and default marshalers on fresh instances; it never inspects or changes loaded values or makes a Handle ready.
Manifest can run before, during or after Load, including after a failed load. Concurrent registrations completed after the snapshot belong to a later call. No loader lock is held while config methods run. These methods must be safe for concurrent calls if Manifest or Load are called concurrently. As with the package-level Manifest, a panic from user code propagates to the caller.
Example ¶
Manifest describes the registered set without reading required ENV values.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
loader := confmaker.MakeLoader()
loader.Register[StoreConfig]("store")
loader.Register[StoreConfig]("replica", confmaker.WithPrefix("REPLICA_STORE_"))
configs, err := loader.Manifest()
if err != nil {
fmt.Println(err)
return
}
for _, config := range configs {
fmt.Println(config.InstanceName, config.Prefix)
for _, variable := range config.Variables {
fmt.Printf("%s=%s\n", variable.Name, variable.Default)
}
}
}
Output: replica REPLICA_STORE_ REPLICA_STORE_HOST= REPLICA_STORE_TIMEOUT=30s store STORE_ STORE_HOST= STORE_TIMEOUT=30s
func (*Loader) Register ¶ added in v0.8.0
func (l *Loader) Register[T any](name string, opts ...ConfigOption) *Handle[T]
Register registers a struct config of type T and returns its Handle. The required name labels errors and determines the default ENV prefix: "read-replica" reads READ_REPLICA_*. WithPrefix overrides only the prefix. Names must be non-empty, contain only lowercase letters, digits, _ - ., and start and end with a letter or digit.
An invalid registration, such as an empty name or bad tag, is reported by Loader.Load or Loader.Manifest. Once Load has started, Register registers nothing and calls no method of T; the handle's Value returns ErrRegisteredAfterLoad.
func (*Loader) WriteEnvExample ¶ added in v0.8.0
WriteEnvExample writes a reference .env.example for the same snapshot as Loader.Manifest. It does not read ENV, load configs or open files. Descriptions become comments. Only non-secret, non-zero scalar defaults made of ASCII letters, digits and _./:@%+,- are emitted as active assignments. Other fields become commented placeholders; complex defaults are not printed. Required fields are marked even when they have a default. Zero defaults cannot be distinguished from absent defaults and also become placeholders.
Names outside [A-Za-z_][A-Za-z0-9_]* are quoted in comments, without changing the names accepted by Load. The output is a reference, not a shell script or a promise of compatibility with every dotenv parser. Manifest errors leave the writer untouched. Writer errors are returned and may leave partial output. Defaults and marshalers have the same lifecycle as Manifest. Secret values are never rendered.
Example ¶
type config struct {
URL string `env:"URL,notEmpty" envDescription:"Service endpoint"`
}
loader := MakeLoader()
loader.Register[config]("api")
var output bytes.Buffer
if err := loader.WriteEnvExample(&output); err != nil {
panic(err)
}
fmt.Print(output.String())
Output: # Configuration: "api" # Service endpoint # Required; must not be empty. # API_URL=
func (*Loader) WriteManifestJSON ¶ added in v0.8.0
WriteManifestJSON writes a version 1 manifest with fixed camelCase keys, two-space indentation and a trailing newline. Empty lists are JSON arrays. All variable metadata is included except secret defaults, whose default key is omitted. hasDefault retains Manifest's non-zero-value semantics.
It uses Loader.Manifest's snapshot and lifecycle: no ENV is read or config loaded. Manifest and encoding errors leave writer untouched. Writer errors are returned and may leave partial output. The caller owns file handling.
Example ¶
loader := MakeLoader()
var output bytes.Buffer
if err := loader.WriteManifestJSON(&output); err != nil {
panic(err)
}
fmt.Print(output.String())
Output: { "version": 1, "configs": [] }
func (*Loader) WriteManifestMarkdown ¶ added in v0.8.0
WriteManifestMarkdown writes a section per config with its prefix and a GitHub-flavored Markdown table of variables in manifest order. Columns show ENV name, Go type, requirement, secret status, default and description. Missing or secret defaults are shown as an em dash. Text is escaped for table cells; description and default line breaks become HTML br elements.
It uses Loader.Manifest's snapshot and lifecycle without reading ENV or loading configs. Manifest errors leave writer untouched. Writer errors are returned and may leave partial output. The caller owns file handling.
Example ¶
type config struct {
URL string `env:"URL,notEmpty" envDescription:"Service endpoint"`
}
loader := MakeLoader()
loader.Register[config]("api")
var output bytes.Buffer
if err := loader.WriteManifestMarkdown(&output); err != nil {
panic(err)
}
fmt.Print(output.String())
Output: # Configuration manifest ## api Prefix: API_ | ENV | Type | Requirement | Secret | Default | Description | | --- | --- | --- | --- | --- | --- | | API_URL | string | Required, non-empty | No | — | Service endpoint |
type Variable ¶ added in v0.6.2
type Variable struct {
// Name is the full variable name, prefix included.
Name string
// Description is the optional envDescription tag on the field.
Description string
// Type is the Go type of the field it fills.
Type string
// Required reports whether the variable must be set: the field is declared
// required or notEmpty.
Required bool
// NotEmpty reports whether the variable must also hold a non-empty value: the
// field is declared notEmpty. Without it, required permits empty text,
// provided the field type can parse it.
NotEmpty bool
// Secret reports whether the field holds a secret value.
Secret bool
// Default is the value the field holds after SetDefaults, rendered as the
// text a variable would carry. It is empty for a secret.
Default string
// HasDefault reports whether the field is non-zero before the environment is
// applied. A default of false, 0 or "" is indistinguishable from no default
// and is reported as false. Secret fields always report false.
HasDefault bool
// contains filtered or unexported fields
}
Variable describes one environment variable a config reads. The set of them comes from the same compiled schema used to load it, so a variable listed here is exactly a variable the config reads.
func Manifest ¶ added in v0.6.2
func Manifest[T any](name string, opts ...ConfigOption) ([]Variable, error)
Manifest returns the variables a config of type T reads, in declaration order, for generating a .env.example, a config map or a documentation table. The required name and options match Loader.Register, so the same arguments describe the same environment variables.
Manifest does not read the environment. It does call T's SetDefaults, and renders each default with MarshalText or the field's plain syntax; a default that cannot be rendered is an error. The result is metadata: Variable.Default is ENV text, not escaped for a shell or YAML.
A declaration Loader.Load would refuse is refused here too, before SetDefaults runs.
Example ¶
Manifest lists the variables and their defaults without reading the environment. The output is ENV names and values, not an escaped shell script.
package main
import (
"fmt"
"time"
"github.com/uchaloop/confmaker"
)
// StoreConfig is the kind of config a library declares: plain fields, env tags,
// and no knowledge of where the values come from.
type StoreConfig struct {
Host string `env:"HOST,notEmpty"`
Timeout time.Duration `env:"TIMEOUT"`
}
func (c *StoreConfig) SetDefaults() { c.Timeout = 30 * time.Second }
func (c StoreConfig) Validate() error {
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive, got %s", c.Timeout)
}
return nil
}
func main() {
variables, err := confmaker.Manifest[StoreConfig]("store")
if err != nil {
fmt.Println(err)
return
}
for _, v := range variables {
fmt.Printf("%s=%s\n", v.Name, v.Default)
}
}
Output: STORE_HOST= STORE_TIMEOUT=30s
