Skip to content

Repository files navigation

confijer

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.

The idea

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: 34
var cfg Config
if err := confijer.UnmarshalYAML(data, &cfg); err != nil {
	log.Fatal(err)
}
  • The top-level key "User" is not just the value of the Config.User field — it's a default template for every field of type User anywhere in the Config tree.
  • "Admin": {"User": {"age": 34}} means: take the default for type User (Name: "Ivan", Address.City: "Moscow"), merge it in, then overlay age: 34 specifically for the Config.Admin.User field.

Result:

cfg.Admin.User == User{Name: "Ivan", Age: 34, Address: Address{City: "Moscow"}}
cfg.User       == User{Name: "Ivan", Age: 0,  Address: Address{City: "Moscow"}}

Install

go get github.com/helmwave/confijer

API

// 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) error

Value priority

For each leaf field, from highest to lowest priority:

  1. an explicit value at the exact path in the config;
  2. Default(field type) — recursively computed default for the Go type;
  3. the struct tag default:"..." on the field;
  4. the zero value.

Struct-tag fallback

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.

Type name collisions

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.

Slices and maps of structs

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" } ]
}

Maps merge by key, slices don't

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.

Pointers to structs

*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.

Cyclic types

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.

Tests

go test ./...

About

universal default configurator

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages