confmaker

package module
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Oct 1, 2026 License: MIT Imports: 18 Imported by: 0

README

confmaker

confmaker

Go Reference CI Release

Explicit, centralized configuration for Go applications. Packages declare what they need; the application registers their configs, loads ENV once and passes typed values to consumers. The core works independently of any framework; confx adapts it to Uber Fx.

Install · Quick start · How it works · Rules · Manifest · Reference

Installation

Requires Go 1.27 or later. This README describes the v0.8.0 API.

go get github.com/uchaloop/confmaker@v0.8.0
import "github.com/uchaloop/confmaker"

The library reads ENV. It does not automatically load .env files, contact secret stores or start services. Set variables through your IDE, shell or deploy system.

Quick start

In a real application, StoreConfig and JobConfig below belong to their respective packages. They need no dependency on confmaker. The application owns the explicit registration list.

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/uchaloop/confmaker"
)

type StoreConfig struct {
	Host    string        `env:"HOST,notEmpty" envDescription:"Database address"`
	Timeout time.Duration `env:"TIMEOUT" envDescription:"Database operation 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")
	}
	return nil
}

type JobConfig struct {
	Workers int `env:"WORKERS"`
}

func (c *JobConfig) SetDefaults() { c.Workers = 2 }

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	loader := confmaker.MakeLoader()
	store := loader.Register[StoreConfig]("postgres")
	job := loader.Register[JobConfig]("job")

	if err := loader.Load(); err != nil {
		return err
	}

	storeConfig, err := store.Value()
	if err != nil {
		return err
	}
	jobConfig, err := job.Value()
	if err != nil {
		return err
	}

	// Pass these values to the store and job constructors.
	fmt.Println(storeConfig.Host, storeConfig.Timeout, jobConfig.Workers)
	return nil
}

Run with POSTGRES_HOST set in your IDE, or from a shell:

export POSTGRES_HOST=localhost:5432
go run .

Output: localhost:5432 30s 2. Unset optional fields retain their defaults. For a single configuration, use confmaker.Load[StoreConfig]("postgres").

How it works

flowchart TD
    A["Packages declare Config structs"] --> B["Application registers types and instance names"]
    B --> C["Check declarations and conflicts"]
    C --> D["Take one ENV snapshot"]
    D --> E["SetDefaults → apply ENV → Validate"]
    E --> F{"Any errors?"}
    F -->|Yes| G["Return a combined error; expose no values"]
    F -->|No| H["Pass typed values to consumers"]

Register compiles the schema; errors are reported by Load or Manifest. Load checks all valid registrations, including ones with no consumer. Conflicts between registrations stop loading before defaults run. Unknown variables are checked under registered prefixes; unrelated ENV variables are ignored.

Load runs once. Later calls return the same result. All handles remain unavailable until the whole set loads successfully. A failed configuration prevents values from being handed out for the entire set.

Names and multiple instances

The instance name is explicit and determines the default ENV prefix:

Registration ENV prefix
Register[StoreConfig]("postgres") POSTGRES_
Register[StoreConfig]("read-replica") READ_REPLICA_
Register[StoreConfig]("replica", confmaker.WithPrefix("READ_DB_")) READ_DB_

Register the same type more than once under different names when configuring independent instances. Names contain lowercase letters, digits and _, -, .; they must start and end with a letter or digit. Names, resolved prefixes and full variable names must not collide. There is no implicit ConfigName() lookup.

WithPrefix changes ENV names, not the instance name. Config options apply to one registration; ENV options such as WithEnv, WithDump and AllowUnknown apply to the loader.

Configuration rules

Tag Meaning
env:"HOST" Read <PREFIX>HOST; absence retains the default
env:"HOST,required" ENV must contain the variable; empty text must still be parseable
env:"HOST,notEmpty" ENV must contain non-empty text
env:"-" Ignore the field and everything inside it
envPrefix:"POOL_" Extend the prefix for a nested config held by value
envDescription:"Database address" Description for manifest and exports
envSeparator:";" Separator for a plain slice or map; default ,
envKeyValSeparator:"=" Key/value separator for a plain map; default :
envFormat:"json" Read a structured value from one JSON variable

Defaults belong in SetDefaults, not envDefault tags. Only the root config's SetDefaults and Validate methods run; explicitly compose nested defaults or validation there when needed. Validate runs only after all fields of that configuration parse successfully. There is no tag that makes all nested fields required: mark individual fields.

Strings, bools, integers, floats, durations, text-unmarshalable types, scalar pointers, and supported slices/maps can be read in plain syntax. Examples: 30s, host-a,host-b, team:core,env:dev.

For structured data, a field such as Endpoints []Endpoint with env:"ENDPOINTS" envFormat:"json" reads one variable. A supplied JSON value replaces the whole default, rather than merging with it. JSON durations use strings such as "30s". See GoDoc for JSON rules, custom types and supported shapes.

Errors and diagnostics

The original error is the complete result. Structured problems let callers inspect configuration failures without parsing messages:

if err := loader.Load(); err != nil {
	for _, problem := range confmaker.ConfigErrors(err) {
		fmt.Printf("%s: config=%s variable=%s field=%s: %v\n",
			problem.Kind, problem.InstanceName, problem.VariableName,
			problem.FieldPath, problem)
	}
	return err
}

Categories distinguish declarations, conflicts, missing or empty variables, parsing, unknown variables, validation and default rendering. errors.As finds the first *confmaker.ConfigError. ConfigErrors collects structured problems through wrappers and joins; a joined user Validate error remains one validation problem. Treat returned error pointers as read-only.

Original non-secret causes remain available through errors.Is and errors.As, including strconv.ErrSyntax, strconv.ErrRange and *strconv.NumError. Writer and lifecycle errors are separate, so always handle the original error even if ConfigErrors returns no entries.

confmaker.WithDump(writer) prints values and their sources during loading. It runs before parsing/validation completes: a dump is diagnostic output, not proof of success. Secret types are masked. Ordinary strings and user validation messages are not automatically sanitized.

Testing

Use WithEnv to replace the process environment with an isolated map. The map is copied when the option is created; WithEnv(nil) means an empty environment.

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

	cfg, err := confmaker.Load[StoreConfig]("postgres", confmaker.WithEnv(map[string]string{
		"POSTGRES_HOST": "localhost:5432",
	}))
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Timeout != 30*time.Second {
		t.Fatalf("unexpected timeout: %s", cfg.Timeout)
	}
}

This test uses StoreConfig from the quick start and imports testing and time. No process-wide ENV changes are needed.

Manifest and configuration documentation

What it describes

A manifest describes what the application declares: config instances, resolved prefixes, ENV names, Go types, required/secret flags, defaults and field descriptions. It is not a snapshot of the application's actual settings.

Use it to inspect the full configuration surface, generate a reference for local setup, publish documentation or supply metadata to external tools. Ordinary application loading does not require it.

Use the same registrations

After registration, choose either loading or description generation. For example, add this branch before loader.Load() in the quick start's run:

if *showManifest {
	return loader.WriteManifestMarkdown(os.Stdout)
}

Define showManifest with flag.Bool("manifest", false, "Print configuration documentation") at package scope, import flag and os, and call flag.Parse() in main before run. Then go run . -manifest prints documentation without loading ENV or starting your services. Keep service construction after the normal load path. No second list of registrations is needed in this core-library example.

For programmatic access after registration:

configs, err := loader.Manifest() // []confmaker.ConfigManifest
if err != nil {
	return err
}
for _, config := range configs {
	fmt.Println(config.InstanceName, config.Prefix)
	for _, variable := range config.Variables {
		fmt.Println(variable.Name, variable.Required, variable.Description)
	}
}

confmaker.Manifest[StoreConfig]("postgres") describes a single type instead. Loader.Manifest also checks conflicts between all registered configurations. The same names and options describe the same ENV variables.

Choose an output
Task API
Inspect metadata in Go loader.Manifest()
Generate an ENV reference loader.WriteEnvExample(writer)
Publish a documentation table loader.WriteManifestMarkdown(writer)
Feed external tools loader.WriteManifestJSON(writer)

All exporters accept io.Writer; the caller opens and closes files. Generate into a buffer first if an existing file must not be truncated on generation failure. Writer failures can still leave partial output.

An .env.example is a reference to keep in version control, not a secrets file or an automatically loaded configuration source. For the quick start's store:

# Configuration: "postgres"
# Database address
# Required; must not be empty.
# POSTGRES_HOST=

# Database operation timeout
POSTGRES_TIMEOUT=30s

Safe non-zero scalar defaults become active assignments. Secrets, complex values and values requiring quoting become commented placeholders. Explicit zero, false and empty-string defaults cannot be distinguished from absent defaults. The template is not a shell script or a guarantee of compatibility with every dotenv parser.

Markdown contains one section per config and a table of variables. JSON uses fixed camelCase keys, two-space indentation and a versioned envelope:

{
  "version": 1,
  "configs": []
}

Config entries contain instanceName, prefix and variables. Variable entries contain name, description, type, required, notEmpty, secret, hasDefault and, for non-secret fields, default. Empty lists are arrays. Consumers should check version. Neither camelCase nor snake_case is mandated by JSON itself; camelCase is this exporter's contract.

Guarantees and boundaries
  • Configs are ordered by instance name; variables retain declaration order.
  • Manifest reads no ENV, calls no Validate, writes no dump and does not make handles ready. It can run before or after Load, including a failed load.
  • Each call evaluates fresh defaults and their marshalers. Keep those methods deterministic and free of side effects; concurrent calls require safe methods.
  • Secret defaults are never rendered. Use a type from secret, not an ordinary string containing credentials. Descriptions are public documentation too.
  • Manifest and all exporters are strict: an invalid declaration, conflict or unrepresentable default fails the whole description before output is written. A commented placeholder does not bypass that check.
  • Manifest does not verify Vault keys, deploy settings or service availability. Such checks belong to external tools consuming its metadata.

Important behavior

Rule Why
Defaults do not satisfy required or notEmpty These tags require the deployment to supply ENV
notEmpty checks text, not the decoded value JSON [] is non-empty text; domain limits belong in Validate
Nested configs are held by value Their structure must not depend on allocation or collection contents
Scalar pointers are supported; pointer-only type cycles are rejected A parser must eventually reach a concrete value
Plain ENV maps reject pointer keys and non-reflexive keys such as NaN Address identity or NaN != NaN would defeat duplicate checks; NaN values remain supported
Map keys and values are not silently trimmed Whitespace must not change configuration unnoticed
Plain collection defaults cannot contain nil pointer chains or ambiguous separators The description must not silently change their meaning
Defaults with unrepresentable text can still load without WithDump Normal loading does not need to render defaults
Loaded configs are read-only by convention Returned structs may share maps, slices and pointers
Unknown variables are checked only under registered prefixes Unrelated process settings belong to other components; use AllowUnknown for explicit exceptions

Reference

  • GoDoc: complete API and type rules.
  • confx: the Uber Fx adapter.
  • Changelog: changes and migration notes.

Acknowledgements

Thanks to the authors of caarlos0/env for the tag conventions and implementation ideas that informed confmaker.

License

MIT

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:

  1. SetDefaults, if the config has it, sets defaults in code.
  2. Each variable that is set replaces its field; an unset variable leaves the default.
  3. 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:

  1. Invalid options and registrations are reported; the other configs still load.
  2. Conflicting registrations - two on one name, prefix or variable - end loading here, before any SetDefaults runs or anything is printed.
  3. One snapshot of the environment is taken.
  4. SetDefaults runs for each config.
  5. The dump is written, when WithDump asks for one. A dump that fails is reported and does not stop the checks.
  6. Variables under a registered prefix that no field reads are reported.
  7. 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

Examples

Constants

This section is empty.

Variables

View Source
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

func AllowUnknown(prefixes ...string) EnvOption

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

func WithDump(w io.Writer) EnvOption

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

func WithEnv(vars map[string]string) EnvOption

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

func (h *Handle[T]) Value() (T, error)

Value returns the loaded config, or an error:

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

func MakeLoader(opts ...EnvOption) *Loader

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

func (l *Loader) Load() error

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

func (l *Loader) WriteEnvExample(writer io.Writer) error

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

func (l *Loader) WriteManifestJSON(writer io.Writer) error

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

func (l *Loader) WriteManifestMarkdown(writer io.Writer) error

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&#95;

| ENV | Type | Requirement | Secret | Default | Description |
| --- | --- | --- | --- | --- | --- |
| API&#95;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

Directories

Path Synopsis
confx module

Jump to

Keyboard shortcuts

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