conf/README.md
juancwu c4ebd80669 Add reflective struct-tag config loader
Implements conf.Load to populate tagged structs from a chain of Sources
(env, .env, YAML/JSON/TOML, custom). Supports default values, slice
separators, nested structs, pointer fields, and a Validator hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:35:51 +00:00

3.8 KiB

conf

Tiny, reflective config loader for Go. Define a struct, tag the fields, call conf.Load. Values come from one or more Sources — env vars, .env files, YAML/JSON/TOML, or anything you implement yourself.

Install

go get git.juancwu.dev/juancwu/conf

Requires Go 1.26+.

Usage

Tag your struct and call Load. The first source that returns a value wins; if no source has it, the default: tag is used.

package main

import (
    "log"
    "strings"
    "time"

    "git.juancwu.dev/juancwu/conf"
    "git.juancwu.dev/juancwu/errx"
)

type Config struct {
    BindAddr            string        `env:"BIND_ADDR" default:":8080"`
    DatabaseURL         string        `env:"DATABASE_URL"`
    SessionCookieSecure bool          `env:"SESSION_COOKIE_SECURE" default:"true"`
    SessionIdleTTL      time.Duration `env:"SESSION_IDLE_TTL" default:"24h"`
    JWTSecret           []byte        `env:"JWT_SECRET"`
    WorkerConcurrency   int           `env:"WORKER_CONCURRENCY" default:"4"`
    Tags                []string      `env:"TAGS" sep:","`
}

func (c *Config) Validate() error {
    const op = "config.Validate"
    if strings.TrimSpace(c.DatabaseURL) == "" {
        return errx.New(op, "DATABASE_URL is required")
    }
    if len(c.JWTSecret) == 0 {
        return errx.New(op, "JWT_SECRET is required")
    }
    return nil
}

func main() {
    var cfg Config
    if err := conf.Load(&cfg, conf.EnvSource()); err != nil {
        log.Fatal(err)
    }
    _ = cfg
}

Multiple sources

Sources are tried in order — earliest wins. Put highest-precedence first:

dotenv, _ := conf.DotEnvFile(".env")
yamlBase, _ := conf.YAMLFile("config.yaml")

err := conf.Load(&cfg,
    conf.EnvSource(), // process env wins
    dotenv,           // .env overrides yaml
    yamlBase,         // base defaults
)

.env files

src, err := conf.DotEnvFile(".env")

Supports KEY=value, export KEY=value, # comments, single/double quotes, and \n \t \r \\ \" escapes inside double quotes.

YAML / JSON / TOML

File sources are flattened into env-style keys: nested maps join with _ and keys are uppercased.

# config.yaml
bind_addr: ":9000"
session:
  idle_ttl: 24h
  cookie:
    secure: true

becomes BIND_ADDR, SESSION_IDLE_TTL, SESSION_COOKIE_SECURE — matching the same env: tags on your struct. JSONFile / TOMLFile work the same way. Each loader has a *Reader variant for io.Reader.

Custom sources

type Source interface {
    Lookup(key string) (string, bool)
}

Implement that to pull from Vault, SSM, a database, or wherever.

Tags

Tag Purpose
env:"KEY" Key looked up in each Source. Required to bind.
env:"-" Skip the field.
default:"v" Used when no Source returns the key.
sep:"," Slice separator (default ,).

Untagged struct fields are recursed into, so you can group related values:

type Config struct {
    HTTP HTTP
}
type HTTP struct {
    Addr string `env:"HTTP_ADDR" default:":8080"`
}

Supported field types

string, []byte, bool, all sized int/uint, float32/float64, time.Duration, time.Time (RFC3339), slices of any scalar above, pointers to any of the above (left nil if unset and no default), and nested structs.

Validation

If your destination type implements Validate() error, it's called after fields are populated. Return an error to fail the load.

Errors

All errors flow through errx with op codes like conf.Load, so errors.Is / errors.As work as usual.

License

MIT — see LICENSE.