A Go library that parses a JSON or YAML config into an arbitrary struct and automatically fills in default values based on a field's Go type, not just its exact position in the tree. Works entirely through reflection — no codegen, no hardcoding for specific types.
type Address struct {
City string
}
type User struct {
Name string
Age int
Address Address
}
type Admin struct {
User User
}
type Config struct {
User User
Admin Admin
}{
"User": { "name": "Ivan", "address": { "city": "Moscow" } },
"Admin": { "User": { "age": 34 } }
}var cfg Config
if err := confijer.Unmarshal(data, &cfg); err != nil {
log.Fatal(err)
}The same in YAML:
User:
name: Ivan
address:
city: Moscow
Admin:
User:
age: 34var cfg Config
if err := confijer.UnmarshalYAML(data, &cfg); err != nil {
log.Fatal(err)
}- The top-level key
"User"is not just the value of theConfig.Userfield — it's a default template for every field of typeUseranywhere in theConfigtree. "Admin": {"User": {"age": 34}}means: take the default for typeUser(Name: "Ivan",Address.City: "Moscow"), merge it in, then overlayage: 34specifically for theConfig.Admin.Userfield.
Result:
cfg.Admin.User == User{Name: "Ivan", Age: 34, Address: Address{City: "Moscow"}}
cfg.User == User{Name: "Ivan", Age: 0, Address: Address{City: "Moscow"}}go get github.com/helmwave/confijer
// Unmarshal parses JSON into out (a pointer to a struct),
// merging type-based defaults on top.
func Unmarshal(data []byte, out any) error
// UnmarshalFile does the same, but reads data from a file.
func UnmarshalFile(path string, out any) error
// UnmarshalYAML does the same, but parses YAML instead of JSON.
func UnmarshalYAML(data []byte, out any) error
// UnmarshalYAMLFile does the same, but reads data from a file.
func UnmarshalYAMLFile(path string, out any) errorFor each leaf field, from highest to lowest priority:
- an explicit value at the exact path in the config;
Default(field type)— recursively computed default for the Go type;- the struct tag
default:"..."on the field; - the zero value.
If a field is still at its zero value after steps 1–2, the default tag
is applied:
type Person struct {
Name string `default:"anon"`
Age int `default:"18"`
}Supported kinds: string, int/uint (any size), float32/float64,
bool.
If two different Go types with the same name appear in the config tree
(e.g. admin.User and billing.User), the bare name "User" in the
config becomes ambiguous. In that case, qualified bucket keys are used:
{
"admin.User": { "name": "Ivan" },
"billing.User": { "name": "Petr" }
}If that qualification also collides, the full import path is used instead. Using a bare name where it's genuinely ambiguous returns an error listing the candidates and the suggested keys.
Each element of a slice/map of a struct type gets Default(element type)
as its base, and explicit data from the config overrides it by
index/key:
{
"User": { "name": "Ivan" },
"Users": [ { "age": 10 }, { "name": "Petr" } ]
}A slice field is wholesale-replaced by whatever the config provides for it (there's no positional key to align old and new entries by). A map field is different: it's merged key by key against whatever base it already had — from a type default, or from a bucket. Keys the explicit map doesn't mention survive from that base; only the keys it does mention get overridden.
type Release struct {
Name string
Labels map[string]string
}
type Config struct {
Releases []Release
}{
"Release": { "Labels": { "team": "platform", "env": "prod" } },
"Releases": [
{ "Name": "api" },
{ "Name": "worker", "Labels": { "team": "data" } }
]
}cfg.Releases[0].Labels == map[string]string{"team": "platform", "env": "prod"}
cfg.Releases[1].Labels == map[string]string{"team": "data", "env": "prod"}worker only overrides team; env still comes through from the
Release type's bucket default.
*T fields are handled the same way as T values: if type T has any
default at all (its own bucket somewhere in the tree) or an explicit value
at the field's path, the pointer will be non-nil. If there's no data for
the field at all, the pointer stays nil.
If type A contains B, and B contains A (through any chain of
structs/pointers/slices/maps), Unmarshal returns an explicit error
instead of recursing forever.
go test ./...