From many sources, one config.
Synthra is a Go package that builds one configuration from many places. It reads from files, environment variables, Consul, in-memory bytes, and any custom source. It merges them in order, validates the result, and binds it to a struct if you want. The name comes from the Greek word synthesis, which means "to put together."
go get gopherly.dev/synthraRequires Go 1.26 or later.
import "gopherly.dev/synthra"flowchart LR
S1[File] --> Merge
S2[Env] --> Merge
S3[Consul] --> Merge
S4[Custom] --> Merge
Merge --> P1
subgraph Pipeline ["pipeline steps (run in registration order)"]
direction LR
P1["Step 0<br>schema / transform / validator"] --> P2
P2["Step 1<br>..."] --> P3
P3["Step N<br>..."]
end
P3 --> Bind["Bind to struct"]
Bind --> Ready["Synthra ready"]
Ready --> Read["Get / String / Int / ..."]
Ready --> Dump["Dump"]
style S1 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style S2 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style S3 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style S4 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style Merge fill:#fef3c7,stroke:#f59e0b,color:#78350f
style P1 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style P2 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style P3 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Pipeline fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
style Bind fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Ready fill:#d1fae5,stroke:#10b981,color:#064e3b
style Read fill:#ede9fe,stroke:#8b5cf6,color:#3b0764
style Dump fill:#ede9fe,stroke:#8b5cf6,color:#3b0764
Most Go services load configuration from more than one place. A YAML file holds the defaults, environment variables override them in production, and a key-value store like Consul holds shared settings. Synthra makes this simple:
- One small API for all sources.
- Clear merge order: later sources win over earlier ones.
- Twelve-Factor friendly: environment variables override files cleanly across environments.
- Automatic format detection from extension (
.yaml,.json,.toml). - JSON Schema defaults fill missing keys automatically.
- Pipeline processing: schema steps, transforms, and validators run in registration order for full flexibility.
- Dynamic schema selection with
WithJSONSchemaFuncbased on a value inside the config. - Two-phase validation: validate before substitution and again after.
- Struct binding with type conversion, defaults, and validation.
- Case-insensitive keys with dot notation (
server.port). - Safe for concurrent use.
- Small core, optional extras. Consul is the only heavy dependency, and you only touch it if you need it.
- A
synthratesthelper package for tests.
- How it works
- Quick start
- Sources
- Formats
- Struct binding
- Default values
- JSON Schema defaults
- Pipeline
- Pipeline callbacks and Configuration/Configurable
- Validation
- Reading values
- Merge order and precedence
- Case insensitivity, casing, and dot notation
- Environment variable naming
- Dumping configuration
- Testing helpers
- Custom sources and codecs
- Error handling
- Thread safety
- Examples
- License
- Contributing
Create a config.yaml file:
server:
host: "localhost"
port: 8080
debug: trueThen load it:
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
)
type Config struct {
Server struct {
Host string `synthra:"host"`
Port int `synthra:"port"`
} `synthra:"server"`
Debug bool `synthra:"debug"`
}
func main() {
var cfg Config
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnv("APP_"),
synthra.WithBinding(&cfg),
)
if err := s.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Printf("listening on %s:%d (debug=%v)\n",
cfg.Server.Host, cfg.Server.Port, cfg.Debug)
}Set APP_SERVER_PORT=9090 to override the YAML port at runtime.
A source is any type whose Load method returns a map[string]any and an error. Synthra ships several built-in sources.
The format comes from the file extension. Supported extensions: .yaml, .yml, .json, .toml.
synthra.WithFile("config.yaml")
synthra.WithFile("config.json")
synthra.WithFile("config.toml")Paths support shell-style environment variable expansion: ${VAR} or $VAR.
synthra.WithFile("${CONFIG_DIR}/app.yaml")Use this when the file has no extension, or when the extension does not match the real format.
import "gopherly.dev/synthra/codec"
synthra.WithFileAs("config", codec.YAML)
synthra.WithFileAs("config.dat", codec.JSON)Useful for embedded files (embed.FS) and tests (testing/fstest.MapFS).
import (
"embed"
"gopherly.dev/synthra"
)
//go:embed config.yaml
var configFS embed.FS
s := synthra.MustNew(
synthra.WithFileFS(configFS, "config.yaml"),
)You can also use WithFileFSAs to pass an explicit decoder.
Pick a prefix. Synthra reads every variable with that prefix, removes it, lowercases the rest, and splits on _ to build a nested map.
synthra.WithEnv("APP_")APP_SERVER_PORT=8080 becomes server.port = "8080".
See Environment variable naming for the full rules.
Pass raw bytes and a decoder. Good for baked-in defaults.
defaults := []byte(`
server:
port: 3000
`)
synthra.WithContent(defaults, codec.YAML)Reads a key from Consul and decodes the value. The format is detected from the path, like for files.
synthra.WithConsul("production/service.yaml")CONSUL_HTTP_ADDR must be set. If it is missing, New returns an error at construction. For dev setups where Consul may not run, gate it with WithIf:
synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
synthra.WithConsul("production/service.yaml"),
)This pattern does nothing when CONSUL_HTTP_ADDR is not set. The token, if any, comes from CONSUL_HTTP_TOKEN.
Use WithConsulAs when the path has no extension, or when the extension does not match the format:
synthra.WithConsulAs("production/service", codec.JSON)
synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
synthra.WithConsulAs("production/service", codec.JSON),
)Implement the Source interface and pass it through WithSource:
type Source interface {
Load(ctx context.Context) (map[string]any, error)
}
s := synthra.MustNew(
synthra.WithSource(mySource),
)The source.NewMap helper is useful for tests and embedded trees:
import "gopherly.dev/synthra/source"
s := synthra.MustNew(
synthra.WithSource(source.NewMap(map[string]any{
"server": map[string]any{"port": 8080},
})),
)The codec package ships ready-to-use codecs:
| Codec | Reads | Writes |
|---|---|---|
codec.YAML |
yes | yes |
codec.JSON |
yes | yes |
codec.TOML |
yes | yes |
codec.EnvVar |
yes | no |
It also offers scalar decoders for single-value sources (for example, a Consul key that holds one number):
codec.ParseInt("port") // bytes -> map{"port": int(...)}
codec.ParseBool("debug")
codec.ParseString("name")
codec.ParseDuration("timeout")
codec.ParseTime("start")
codec.ParseAs("count", strconv.Atoi) // generic parserBinding turns the merged map into a typed struct. Add WithBinding and pass a pointer to a struct.
type Config struct {
Host string `synthra:"host"`
Port int `synthra:"port"`
Timeout time.Duration `synthra:"timeout"`
Roles []string `synthra:"roles"`
URL *url.URL `synthra:"url"`
}
var cfg Config
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithBinding(&cfg),
)
if err := s.Load(context.Background()); err != nil {
log.Fatal(err)
}The default struct tag is synthra. You can pick another tag name:
synthra.WithTag("cfg")Built-in type conversions:
- Strings, numbers, and booleans through the
castlibrary. time.Durationfrom strings like"30s"or"5m".time.Timefrom RFC 3339 strings (for example"2025-01-01T00:00:00Z").*url.URLfrom any string URL.- Slices from comma-separated strings or YAML/JSON arrays.
Use the default struct tag for fallback values. A default applies only when the field stays at its zero value after binding.
type Config struct {
Host string `synthra:"host" default:"localhost"`
Port int `synthra:"port" default:"8080"`
Debug bool `synthra:"debug" default:"false"`
Timeout time.Duration `synthra:"timeout" default:"30s"`
}You can also pass in defaults as a source. This is good when you want them visible in the merged map (for example, for Dump):
defaults := []byte(`server: { port: 3000 }`)
synthra.MustNew(
synthra.WithContent(defaults, codec.YAML),
synthra.WithFile("config.yaml"),
synthra.WithEnv("APP_"),
)When you pass a schema with WithJSONSchema, Synthra automatically extracts every "default" value declared in the schema and applies it to any key that is missing from the loaded configuration. This happens after sources are merged and before validation runs, so the schema validator always sees a fully populated map.
schema := []byte(`{
"type": "object",
"properties": {
"port": {"type": "integer", "default": 8080},
"log_level": {"type": "string", "default": "info",
"enum": ["debug", "info", "warn", "error"]}
}
}`)
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithJSONSchema(schema),
)
// If config.yaml omits "port" and "log_level", they are set to 8080 and "info"
// before validation. Values present in config.yaml are never overridden.Defaults are applied at every level of nesting, including patternProperties. For dynamic key names (like a map of named components), Synthra applies the patternProperties defaults to every existing key that matches the pattern:
schema := []byte(`{
"properties": {
"components": {
"patternProperties": {
"^[a-z0-9-]+$": {
"properties": {
"role": {"type": "string", "default": "service"},
"replicas": {"type": "integer", "default": 1}
}
}
}
}
}
}`)
// config.yaml:
// components:
// web:
// image: nginx
// worker:
// image: my-app
// replicas: 3
//
// After Load:
// components.web.role => "service" (from schema default)
// components.web.replicas => 1 (from schema default)
// components.worker.role => "service" (from schema default)
// components.worker.replicas => 3 (from config.yaml, not overridden)After sources are merged, Synthra executes a pipeline of steps in the order they were registered. Each call to a pipeline option adds one step to the list. Steps run strictly in registration order; there is no implicit ordering between schema, transform, and validator steps.
Pipeline step options:
| Option | What it does |
|---|---|
WithJSONSchema(bytes) |
Applies schema defaults then validates at this point in the pipeline. Schema bytes are validated at construction. |
WithJSONSchemaFunc(selector) |
Same as WithJSONSchema but schema bytes come from a callback that receives the current values. Use this when the schema depends on a value inside the config (e.g. apiVersion). |
WithTransform(fn) |
Applies an arbitrary map mutation at this point. |
WithEnvSubst(r) |
Expands ${VAR} placeholders in all string values using a single Resolver. Compose multiple sources with .Or. Sugar over WithTransform. |
WithValidator(fn) |
Runs a read-only check. Does not modify values. |
Because steps are ordered, you can place a schema before a transform, a transform before a validator, or anything else you need.
Use WithJSONSchemaFunc when the schema depends on a value inside the config itself. The most common case is an apiVersion field:
cfg := synthra.MustNew(
synthra.WithFile("manifest.yaml"),
synthra.WithJSONSchemaFunc(func(_ context.Context, v *synthra.Configurable) ([]byte, error) {
version, err := v.String("apiVersion")
if err != nil || version == "" {
return nil, errors.New("apiVersion is required")
}
return schemaRegistry.Get(version) // your own lookup
}),
)Multiple WithJSONSchema and WithJSONSchemaFunc calls are fully supported; each adds an independent schema step at the point it was registered.
Register a schema step before substitution to validate raw fields, then register another after substitution to validate the final form:
cfg := synthra.MustNew(
synthra.WithFile("manifest.yaml"),
// Step 1: validate the "environments" block on raw values.
synthra.WithJSONSchemaFunc(func(_ context.Context, _ *synthra.Configurable) ([]byte, error) {
return environmentsSchema, nil
}),
// Step 2: expand ${VAR} placeholders from OS environment.
synthra.WithEnvSubst(synthra.FromEnv()),
// Step 3: validate the fully-substituted manifest.
synthra.WithJSONSchemaFunc(func(_ context.Context, _ *synthra.Configurable) ([]byte, error) {
return manifestSchema, nil
}),
)See examples/multi-schema for a runnable demonstration.
WithTransform registers a function that processes the merged configuration map at the point it was registered. Multiple transforms run in registration order.
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithTransform(func(_ context.Context, v *synthra.Configurable) error {
if level := v.StringOr("logLevel", ""); level != "" {
return v.Set("logLevel", strings.ToLower(level))
}
return nil
}),
synthra.WithJSONSchema(schema), // validates the normalized values
)WithEnvSubst is a convenience transform that expands POSIX-style ${VAR} placeholders in all string values. It supports defaults (${VAR:-fallback}), uppercase conversion (${VAR^^}), prefix stripping (${VAR#prefix}), and more.
Variable lookup is handled by a single Resolver. Compose multiple sources with .Or. The first resolver that reports found wins (highest priority first):
// OS environment only
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv()),
)
// Static map
s = synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithJSONSchema(schema),
synthra.WithEnvSubst(synthra.FromMap(map[string]string{
"ENV": "production",
"REGION": "eu-west-1",
})),
)
// If config.yaml has: envFile: ".env.${ENV}"
// After Load: envFile => ".env.production"Layer multiple resolvers for priority-based substitution using .Or. The first resolver to find a given variable name wins (highest priority first):
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
s := synthra.MustNew(
synthra.WithFile("deployah.yaml"),
synthra.WithEnvSubst(
synthra.FromEnv().Prefix("DPY_VAR_"). // highest: prefixed OS env
Or(envFile). // middle: .env file
Or(synthra.FromMap(manifestVars)), // lowest: static defaults
),
)
// config.yaml: port: ${PORT:-3000}
// If DPY_VAR_PORT=9090 is set, port becomes "9090".
// If DPY_VAR_PORT is not set but PORT is in the .env file, that value is used.
// If neither is set, the ${VAR:-default} fallback provides "3000".Each variable lookup walks the chain from left to right and stops at the first resolver that reports found:
flowchart LR
Var["${PORT:-3000}"] --> R1
Out["resolved value"]
subgraph Chain ["resolver chain (first match wins)"]
direction LR
R1["FromEnv().Prefix(DPY_VAR_)"] -->|"not found"| R2
R2["FromEnvFile(.env)"] -->|"not found"| R3
R3["FromMap(defaults)"] -->|"not found"| Fallback["inline default"]
end
R1 -->|found| Out
R2 -->|found| Out
R3 -->|found| Out
Fallback --> Out
style Var fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style R1 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style R2 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style R3 fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Chain fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
style Fallback fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Out fill:#d1fae5,stroke:#10b981,color:#064e3b
The available resolver constructors are:
synthra.FromMap(m): looks up variables from amap[string]stringsynthra.FromEnv(): looks up variables usingos.LookupEnv(reads live env at Load time)synthra.FromEnvFile(path): parses a.envfile eagerly and returns a map-backed resolver; returns an error if the file is missing or malformed.Prefix(prefix): method on anyResolverthat prepends a namespace prefix to each lookup (e.g.FromEnv().Prefix("APP_")resolvesPORTfromAPP_PORT).Or(fallbacks...): method on anyResolverthat chains fallbacks with first-match-wins semantics
Load a .env file and combine it with OS env (OS env wins via first match):
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv().Or(envFile)),
)Use WithEnvSubstFunc when the resolver depends on values already loaded from sources. For example, a .env file path stored inside the config file:
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubstFunc(func(_ context.Context, v *synthra.Configurable) (synthra.Resolver, error) {
envPath := v.StringOr("envfile", "")
if envPath == "" {
return synthra.FromEnv(), nil
}
envFile, err := synthra.FromEnvFile(envPath)
if err != nil {
return nil, err
}
// OS env takes priority over the .env file.
return synthra.FromEnv().Or(envFile), nil
}),
)WithEnv and WithEnvSubst solve different problems:
WithEnvis a source. It reads environment variables and adds them to the config map. For example,APP_SERVER_PORT=8080becomesserver.port. Use this when you want env vars to be config keys.WithEnvSubstis a transform. It expands${VAR}placeholders that are already present in string values loaded from files or other sources. Use this when your config files contain placeholder strings that should be filled from the environment or a map.
Both can be used together. They do not overlap.
WithEnvSubst also works with patternProperties defaults. If the schema default for a field is ".env.${NAME}", the substitution runs after the default is applied and fills in the placeholder.
WithTransform, WithEnvSubstFunc, WithValidator, and WithJSONSchemaFunc receive typed wrappers instead of a raw map[string]any. The wrappers give you safe, case-insensitive, typed access.
WithTransformandWithEnvSubstFuncreceive*synthra.Configurable— the mutable view. UseSet,Delete,Walk, andRawto modify the configuration.WithValidatorandWithJSONSchemaFuncreceive*synthra.Configuration— the read-only view. Enforced at the type level: validators cannot mutate the map.
Configurable embeds Configuration, so all read methods are available on both types.
All callbacks receive a context.Context as the first argument, enabling cancellation, timeouts, and tracing.
Direct map access is case-sensitive at the Go language level. If your YAML says apiVersion but the merged map ends up storing it under a slightly different casing, values["apiVersion"] returns nil. The wrappers fix that. All methods are case-insensitive.
c.Get("metadata.name") // any, case-insensitive
c.Has("server.tls.enabled") // bool
c.String("apiVersion") // (string, error)
c.IntOr("server.port", 8080) // int with default_ = v.Set("metadata.region", "eu-west-1") // creates intermediate maps
v.Delete("debug.experimental")v.Walk(func(path string, val any) (any, bool) {
if s, ok := val.(string); ok && strings.HasPrefix(s, "${") {
return strings.TrimPrefix(s, "${"), true
}
return val, false
})When a key holds a slice of objects (e.g. an "environments" array), use the built-in accessors to avoid .([]any) / .(map[string]any) type assertions:
// Count elements
n := v.SliceLen("environments")
// Iterate map elements (non-map elements are skipped)
for i, e := range v.EachMap("environments") {
fmt.Println(i, e.StringOr("name", ""))
}
// Find by field value (case-insensitive)
env := v.Find("environments", "name", "prod")
if env == nil {
return errors.New("environment prod not found")
}
// Find with a predicate (short-circuits on first match)
env = v.FindFunc("environments", func(e *synthra.Configurable) bool {
return e.IntOr("port", 0) == 443
})Elements returned by Find and EachMap on *Configurable share the parent's underlying map: mutations via Set/Delete reach back to the parent.
When you must hand the underlying map to code that expects a plain map[string]any, call v.Raw() on *Configurable. Mutations on the returned map are visible through the same *Configurable.
WithTransform, WithValidator, WithEnvSubstFunc, and WithJSONSchemaFunc run at the map stage, before binding.
OnBound[T] is a binding-scoped option that goes inside WithBinding[T]. It runs at the binding stage: after the bound struct is decoded and defaults applied, but before its Validate() method.
synthra.WithFile("config.yaml"),
synthra.WithTransform(func(_ context.Context, v *synthra.Configurable) error {
if v.StringOr("env", "dev") == "prod" {
return v.Set("logging.level", "warn")
}
return nil
}),
synthra.WithBinding(&app,
synthra.OnBound(func(a *App) error {
a.Logging.Level = strings.ToLower(a.Logging.Level)
return nil
}),
),Because OnBound[T] is a sub-option of WithBinding[T], Go infers the same T for both. If the closure type does not match the binding target, you get a compile error, not a runtime panic:
var server Server
synthra.WithBinding(&server,
synthra.OnBound(func(a *App) error { ... }), // compile error
)Here is the full sequence when Load runs:
flowchart TB
Sources["sources merged (last wins)"] --> MapStage
subgraph MapStage ["map stage"]
direction TB
Steps["pipeline steps in order<br>schema / transform / envsubst / validator"]
end
MapStage --> Bind["bind into struct<br>mapstructure + default tags"]
Bind --> BindStage
subgraph BindStage ["binding stage"]
direction TB
Hooks["OnBound[T] hooks (in order)"] --> Val["Validate() if implemented"]
end
BindStage --> Ready["ready"]
style Sources fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style Steps fill:#fef3c7,stroke:#f59e0b,color:#78350f
style MapStage fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
style Bind fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Hooks fill:#fef3c7,stroke:#f59e0b,color:#78350f
style Val fill:#fef3c7,stroke:#f59e0b,color:#78350f
style BindStage fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
style Ready fill:#d1fae5,stroke:#10b981,color:#064e3b
Synthra supports three ways to validate, and you can combine them.
Add a Validate() error method on your struct. Synthra calls it after binding.
type Config struct {
Port int `synthra:"port"`
}
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535, got %d", c.Port)
}
return nil
}Pass a schema as raw bytes. Synthra fills in "default" values, then validates the merged map before binding.
schema := []byte(`{
"type": "object",
"required": ["service", "port"],
"properties": {
"service": {"type": "string", "minLength": 1},
"port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8080}
}
}`)
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithJSONSchema(schema),
)Synthra supports JSON Schema Draft 4, Draft 6, Draft 7, Draft 2019-09, and Draft 2020-12. See JSON Schema defaults for details on default application.
Use WithValidator for cross-field rules or any logic that does not fit a schema.
synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
if !c.Has("server.tls") {
return nil
}
if enabled := c.BoolOr("server.tls.enabled", false); enabled {
if !c.Has("server.tls.cert") || !c.Has("server.tls.key") {
return errors.New("tls.cert and tls.key are required when tls.enabled is true")
}
}
return nil
})To report multiple errors at once in a single validator, use errors.Join:
synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
var errs []error
if c.StringOr("app.env", "") == "" {
errs = append(errs, errors.New("app.env is required"))
}
if c.IntOr("port", 0) < 1 {
errs = append(errs, errors.New("port must be positive"))
}
return errors.Join(errs...)
})You can add more than one validator. Synthra runs them in order. The first error stops Load.
After Load, you have several ways to read values.
If you used WithBinding, just use the struct.
fmt.Println(cfg.Server.Host, cfg.Server.Port)These return an error when the key is missing or the value cannot be converted.
port, err := s.Int("server.port")
host, err := s.String("server.host")
debug, err := s.Bool("debug")
rate, err := s.Float64("rate")
timeout, err := s.Duration("timeout")
when, err := s.Time("start_time")
tags, err := s.StringSlice("tags")
ports, err := s.IntSlice("ports")
meta, err := s.StringMap("metadata")Use errors.Is(err, synthra.ErrKeyNotFound) to check for a missing key.
These never return an error. They return the default when the key is missing or cannot be converted.
host := s.StringOr("server.host", "localhost")
port := s.IntOr("server.port", 8080)
debug := s.BoolOr("debug", false)
timeout := s.DurationOr("timeout", 30*time.Second)
tags := s.StringSliceOr("tags", []string{"default"})Other Or methods exist for Int64, Float64, Time, IntSlice, and StringMap. See the API docs for the full list.
For type-safe access with one function, use the generic helpers:
port, err := synthra.Get[int](s, "server.port")
host := synthra.GetOr(s, "server.host", "localhost")The type comes from the type parameter, or from the default value.
Get(key) returns the value as any. It returns nil when the key is missing.
v := s.Get("server.port") // anyValues() returns a copy of the merged map. Treat it as read-only.
all := s.Values() // *map[string]anySources are merged in the order you add them. Later sources override earlier ones. Nested maps merge by key. Other values (strings, numbers, slices) are replaced as a whole.
synthra.MustNew(
synthra.WithContent(defaults, codec.YAML), // 1. baked-in defaults
synthra.WithFile("config.yaml"), // 2. file on disk
synthra.WithFile("override.json"), // 3. another file
synthra.WithEnv("APP_"), // 4. environment (wins)
)In this example, environment variables have the highest precedence.
Synthra keeps the casing your config sources use. If your file says apiVersion, the loaded map will have apiVersion too. Only the matching is case-insensitive: you can read the same key as apiVersion or APIVERSION and get the same value.
s.Int("server.port") // works
s.Int("Server.Port") // also works
s.Int("SERVER.PORT") // also worksKeys use dot notation: server.port walks into server and reads port.
Environment variables are uppercase by convention (APP_API_VERSION), so the env source lowercases them to produce a nested map. A WithEnv("APP_") source always contributes lowercase keys like apiversion. When env meets another source that already has the same key in a different casing, the case-insensitive merge keeps the first source's casing and overrides only the value. So if your YAML says apiVersion: v1 and APP_APIVERSION=v2 is set, the final map has apiVersion: v2.
When two non-env sources use different casings for the same key, the first source wins for the name and the last source wins for the value. So if base.yaml has ApiVersion: v1 and override.yaml has apiVersion: v2, the final map looks like ApiVersion: v2. The typo in the base file is preserved.
To avoid that, register a JSON Schema. Before validation runs, Synthra renames any case-different keys in the data to match the schema's "properties" declarations. So ApiVersion: v2 becomes apiVersion: v2 if your schema says "properties": {"apiVersion": ...}.
# base.yaml -> ApiVersion: v1
# override.yaml -> apiVersion: v2
# result: ApiVersion: v2 (first writer's casing wins)
# Same files, with a schema declaring apiVersion:
# result: apiVersion: v2 (schema is the authority)
# config.yaml -> apiVersion: v1
# APP_APIVERSION=v2
# result: apiVersion: v2 (YAML casing wins, env overrides value)
patternProperties and additionalProperties dynamic keys are not renamed by the schema. Keys inside list elements are only renamed when the schema declares an items object for that list.
Given prefix APP_:
- The prefix is removed.
- The rest is lowercased.
- Underscores split into nested keys.
| Variable | Key |
|---|---|
APP_PORT=8080 |
port |
APP_SERVER_HOST=db |
server.host |
APP_DATABASE_PRIMARY_HOST=db |
database.primary.host |
APP_TAGS=a,b,c |
tags (string, splits to slice on read) |
A field like server.read.timeout maps to APP_SERVER_READ_TIMEOUT when the prefix is APP_.
Synthra can write the merged configuration to a file. The format comes from the file extension, just like for sources.
s := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnv("APP_"),
synthra.WithFileDumper("effective.yaml"), // format from extension
)
s.Load(context.Background())
s.Dump(context.Background()) // writes effective.yamlFor an explicit format:
synthra.WithFileDumperAs("output", codec.JSON)You can also write your own dumper by implementing the Dumper interface and passing it with WithDumper.
type Dumper interface {
Dump(ctx context.Context, values *map[string]any) error
}The synthratest package provides helpers for tests.
import (
"testing"
"github.com/stretchr/testify/require"
"gopherly.dev/synthra"
"gopherly.dev/synthra/source"
"gopherly.dev/synthra/synthratest"
)
func TestServer(t *testing.T) {
cfg := synthratest.Load(t, map[string]any{
"server": map[string]any{"port": 8080, "host": "127.0.0.1"},
})
port, err := cfg.Int("server.port")
require.NoError(t, err)
require.Equal(t, 8080, port)
}Highlights:
synthratest.Config(t, opts...): build a*Synthrawithout callingLoad.synthratest.Load(t, map, opts...): build and load with a map source.synthratest.LoadFile(t, format, content): write a temp file and load it.synthratest.WriteFile(t, format, content): write a temp config file and return its path.synthratest.Dumper: a recording dumper for tests.synthratest.FuncCodec: a codec test double with function fields forDecodeandEncode.synthratest.ErrSource(err): a source that always returns the given error.synthratest.AssertString,AssertInt,AssertBool,AssertStringSlice,AssertDumped: shortcut assertions.
Implement Source:
type vaultSource struct {
path string
}
func (s *vaultSource) Load(ctx context.Context) (map[string]any, error) {
// fetch from your secret store
return map[string]any{
"db": map[string]any{
"password": "from-vault",
},
}, nil
}
synthra.WithSource(&vaultSource{path: "secret/data/db"})Implement codec.Codec (or codec.Decoder only if you do not need to dump):
type myCodec struct{}
func (myCodec) Decode(data []byte, v any) error { /* ... */ }
func (myCodec) Encode(v any) ([]byte, error) { /* ... */ }
synthra.WithFileAs("config.custom", myCodec{})Synthra returns structured errors of type *ConfigError. They follow the shape of os.PathError:
type ConfigError struct {
Op string // "new", "load", "dump", or "get"
Path string // where the error happened (source index, field, step index, ...)
Err error // the underlying cause
}Pipeline step errors use the path format "step[N]:kind" where N is the zero-based step index and kind is "schema", "transform", or "validator". For example, "step[0]:schema" means the first registered schema step failed.
Use errors.As to read the operation:
if err := s.Load(ctx); err != nil {
var ce *synthra.ConfigError
if errors.As(err, &ce) {
log.Error("load failed", "op", ce.Op, "path", ce.Path, "err", ce.Err)
}
return err
}Use errors.Is for fixed reasons:
_, err := s.Int("server.port")
if errors.Is(err, synthra.ErrKeyNotFound) {
return useDefaultPort()
}Sentinel errors:
synthra.ErrNilConfig: a typed accessor was called on a nil*Synthra.synthra.ErrKeyNotFound: the key is missing for a strict accessor.synthra.ErrNilContext:LoadorDumpgot a nil context.
New can return a joined error when more than one option is invalid. Use errors.As on it the same way.
A *Synthra is safe for use by many goroutines:
Loadcan be called many times. The internal map is replaced atomically when loading succeeds.- All read methods (
Get,String,Int,Values, ...) hold a read lock. Dumpreads a snapshot of the current values, so dumpers do not block reads.- The bound struct is not protected. If you re-load while another goroutine reads the struct, you are responsible for synchronizing access yourself.
The examples/ folder has small, runnable programs. Each one has its own README and tests.
| Folder | Topic |
|---|---|
basic |
YAML file and struct binding |
webapp |
YAML defaults plus WEBAPP_* overrides, binding, and Validate |
testing |
synthratest.Config and source.NewMap |
schema |
WithJSONSchema defaults and patternProperties |
casing |
Case-insensitive merge and schema as casing authority |
hooks |
WithTransform, WithValidator, and OnBound[T] in one pipeline |
codecs |
WithFileAs (JSON, TOML) and WithFileDumperAs (YAML dump) |
envsubst-layered |
Three-layer Resolver.Or precedence |
multi-schema |
Two-phase validation with WithJSONSchemaFunc and EachMap |
consul |
Optional Consul source via WithIf |
Run them all with:
go test ./examples/...Synthra is released under the Apache License 2.0.
Join #gopherly on the Gophers Slack for discussion and updates.
Contributions are welcome. Please open an issue first to discuss larger changes before sending a pull request.
This project uses Nix for development. Run nix develop to enter the shell, then:
nix run .#lintto run the linter and check formatting.nix run .#fmtto auto-fix formatting.nix run .#test-unitto run unit tests.