synthra

package module
v0.12.0 Latest Latest
Warning

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

Go to latest
Published: May 20, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

Synthra

CI codecov Go Reference Go Report Card License Slack

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/synthra

Requires Go 1.26 or later.

import "gopherly.dev/synthra"

How it works

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

Why Synthra

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 WithJSONSchemaFunc based 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 synthratest helper package for tests.

Contents

  1. How it works
  2. Quick start
  3. Sources
  4. Formats
  5. Struct binding
  6. Default values
  7. JSON Schema defaults
  8. Pipeline
  9. Pipeline callbacks and Configuration/Configurable
  10. Validation
  11. Reading values
  12. Merge order and precedence
  13. Case insensitivity, casing, and dot notation
  14. Environment variable naming
  15. Dumping configuration
  16. Testing helpers
  17. Custom sources and codecs
  18. Error handling
  19. Thread safety
  20. Examples
  21. License
  22. Contributing

Quick start

Create a config.yaml file:

server:
  host: "localhost"
  port: 8080
debug: true

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

Sources

A source is any type whose Load method returns a map[string]any and an error. Synthra ships several built-in sources.

File with automatic format detection

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")
File with explicit format

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)
File inside an io/fs.FS

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.

Environment variables

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.

In-memory content

Pass raw bytes and a decoder. Good for baked-in defaults.

defaults := []byte(`
server:
  port: 3000
`)

synthra.WithContent(defaults, codec.YAML)
Consul key-value store

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),
)
Custom source

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},
    })),
)

Formats

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 parser

Struct binding

Binding 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 cast library.
  • time.Duration from strings like "30s" or "5m".
  • time.Time from RFC 3339 strings (for example "2025-01-01T00:00:00Z").
  • *url.URL from any string URL.
  • Slices from comma-separated strings or YAML/JSON arrays.

Default values

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_"),
)

JSON Schema defaults

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)

Pipeline

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.

Dynamic schema selection

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.

Two-phase validation

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.

Transforms

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 a map[string]string
  • synthra.FromEnv(): looks up variables using os.LookupEnv (reads live env at Load time)
  • synthra.FromEnvFile(path): parses a .env file eagerly and returns a map-backed resolver; returns an error if the file is missing or malformed
  • .Prefix(prefix): method on any Resolver that prepends a namespace prefix to each lookup (e.g. FromEnv().Prefix("APP_") resolves PORT from APP_PORT)
  • .Or(fallbacks...): method on any Resolver that 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:

  • WithEnv is a source. It reads environment variables and adds them to the config map. For example, APP_SERVER_PORT=8080 becomes server.port. Use this when you want env vars to be config keys.
  • WithEnvSubst is 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.

Pipeline callbacks and Configuration/Configurable

WithTransform, WithEnvSubstFunc, WithValidator, and WithJSONSchemaFunc receive typed wrappers instead of a raw map[string]any. The wrappers give you safe, case-insensitive, typed access.

  • WithTransform and WithEnvSubstFunc receive *synthra.Configurable — the mutable view. Use Set, Delete, Walk, and Raw to modify the configuration.
  • WithValidator and WithJSONSchemaFunc receive *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.

Why typed wrappers

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.

Reading
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
Writing (only on *Configurable)
_ = v.Set("metadata.region", "eu-west-1") // creates intermediate maps
v.Delete("debug.experimental")
Walking the tree (only on *Configurable)
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
})
Array-of-object navigation

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.

Escape hatch

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.

Map stage vs binding stage

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
    }),
),
Compile-time safety for OnBound

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

Validation

Synthra supports three ways to validate, and you can combine them.

1. Validator interface on the bound struct

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
}
2. JSON Schema

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.

3. Custom validator function

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.

Reading values

After Load, you have several ways to read values.

Bound struct (preferred for typed code)

If you used WithBinding, just use the struct.

fmt.Println(cfg.Server.Host, cfg.Server.Port)
Strict typed methods

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.

"Or" methods with a default

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.

Generic Get and GetOr

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.

Raw access

Get(key) returns the value as any. It returns nil when the key is missing.

v := s.Get("server.port")  // any

Values() returns a copy of the merged map. Treat it as read-only.

all := s.Values() // *map[string]any

Merge order and precedence

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

Case insensitivity, casing, and dot notation

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 works

Keys use dot notation: server.port walks into server and reads port.

The one exception: environment variables

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.

Two sources with different casing

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.

Environment variable naming

Given prefix APP_:

  1. The prefix is removed.
  2. The rest is lowercased.
  3. 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_.

Dumping configuration

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

For 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
}

Testing helpers

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 *Synthra without calling Load.
  • 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 for Decode and Encode.
  • synthratest.ErrSource(err): a source that always returns the given error.
  • synthratest.AssertString, AssertInt, AssertBool, AssertStringSlice, AssertDumped: shortcut assertions.

Custom sources and codecs

Custom source

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"})
Custom codec

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{})

Error handling

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: Load or Dump got a nil context.

New can return a joined error when more than one option is invalid. Use errors.As on it the same way.

Thread safety

A *Synthra is safe for use by many goroutines:

  • Load can be called many times. The internal map is replaced atomically when loading succeeds.
  • All read methods (Get, String, Int, Values, ...) hold a read lock.
  • Dump reads 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.

Examples

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

License

Synthra is released under the Apache License 2.0.

Community

Join #gopherly on the Gophers Slack for discussion and updates.

Contributing

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 .#lint to run the linter and check formatting.
  • nix run .#fmt to auto-fix formatting.
  • nix run .#test-unit to run unit tests.

Documentation

Overview

Package synthra synthesizes configuration for Go applications from many sources into one coherent runtime state.

The name follows σύνθεσις (synthesis): to put together, to compose into a whole. Modern systems are configured in layers: files, environment variables, defaults, flags, secret stores, and remote providers. Each layer is incomplete alone; Synthra merges them in order (later overrides earlier), validates, binds to structs, and exposes the result through Synthra. **From many sources, one state.**

Key casing

Synthra keeps the casing your config sources use. If your file says `apiVersion`, the loaded map will have `apiVersion` too. This applies to all sources (YAML, JSON, TOML, Consul, embedded files, inline content, and any custom source you write). Only the matching is case-insensitive: you can read the same key as `apiVersion` or `APIVERSION` and get the same value.

The one exception is environment variables. 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. So `ApiVersion: v2` becomes `apiVersion: v2` if your schema says `"properties": {"apiVersion": ...}`.

cfg.Get("apiVersion") == cfg.Get("apiversion")  // both work

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

Keys without a schema declaration keep whatever casing the first source provided. The env source always produces lowercase keys. `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.

The package uses the same functional options pattern as other Gopherly packages: options apply to an internal config struct, and the constructor validates and builds the public Synthra from it. The returned Synthra is the runtime object used for Load, Get, and Dump.

Key Features

  • Multiple configuration sources (files, io/fs.FS, environment variables, Consul)
  • Automatic format detection and decoding (JSON, YAML, TOML)
  • Pipeline processing: schema steps, transforms, and validators are executed in registration order, enabling multi-phase workflows
  • JSON Schema defaults: "default" values declared in the schema are automatically applied to missing keys, including patternProperties
  • Dynamic schema selection (WithJSONSchemaFunc) for version-based or content-based schema routing at Load time
  • POSIX-style variable substitution (WithEnvSubst) and arbitrary transforms (WithTransform) can be interleaved with schema steps
  • Composable variable resolvers (FromMap, FromEnv, FromEnvFile, FromEnvFileIfExists, CoalesceEnvFile) for maps, OS env, and .env files; optional and cascading file lookups; prefix stripping via Resolver.Prefix and first-wins fallback chains via Resolver.Or
  • Struct binding with automatic type conversion
  • Validation using JSON Schema or custom validators
  • Case-insensitive key access with dot notation
  • Thread-safe configuration loading and access
  • Configuration dumping to files or custom destinations

Quick Start

Create a configuration instance with sources. Options are applied in order; any validation errors are reported when the config is built (by New or MustNew). Options must not be nil; passing a nil option results in a validation error.

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnv("APP_"),
)

Load the configuration:

if err := cfg.Load(context.Background()); err != nil {
    log.Fatal(err)
}

Access configuration values (strict reads return an error if the key is missing or the value cannot be coerced; use *Or methods for defaults):

port, err := cfg.Int("server.port")
if err != nil {
    log.Fatal(err)
}
host := cfg.StringOr("server.host", "localhost")
debug, err := cfg.Bool("debug")
if err != nil {
    log.Fatal(err)
}

Configuration Sources

The package supports multiple configuration sources that can be combined:

Files with automatic format detection:

synthra.WithFile("config.yaml")     // Detects YAML
synthra.WithFile("config.json")     // Detects JSON
synthra.WithFile("config.toml")     // Detects TOML

Files with explicit format:

synthra.WithFileAs("config", codec.YAML)

Virtual files inside an io/fs.FS (tests, embed.FS, etc.):

synthra.WithFileFS(fsys, "config.yaml")
synthra.WithFileFSAs(fsys, "config", codec.YAML)

Environment variables with prefix:

synthra.WithEnv("APP_")  // Loads APP_SERVER_PORT as server.port

Consul key-value store (CONSUL_HTTP_ADDR required; construction fails if unset):

synthra.WithConsul("production/service.yaml")

Conditional Consul (e.g. for local dev without Consul):

synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
    synthra.WithConsul("production/service.yaml"),
)

Raw content:

yamlData := []byte("port: 8080")
synthra.WithContent(yamlData, codec.YAML)

Struct Binding

Bind configuration to a struct for type-safe access:

type AppConfig struct {
    Port    int           `synthra:"port"`
    Host    string        `synthra:"host"`
    Timeout time.Duration `synthra:"timeout"`
    Debug   bool          `synthra:"debug" default:"false"`
}

var appConfig AppConfig
cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithBinding(&appConfig),
)

if err := cfg.Load(context.Background()); err != nil {
    log.Fatal(err)
}

// Access typed fields directly
fmt.Printf("Server: %s:%d\n", appConfig.Host, appConfig.Port)

Validation

Validate configuration using struct methods:

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")
    }
    return nil
}

Validate using JSON Schema (also applies "default" values automatically):

schema := []byte(`{
    "type": "object",
    "properties": {
        "port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8080}
    },
    "required": ["port"]
}`)

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithJSONSchema(schema), // validates AND fills in "default" values
)
// If config.yaml omits "port", Load sets it to 8080 before validating.

Pipeline

After all sources are merged, Synthra executes pipeline steps in the order they were registered. Steps are added by:

  • WithJSONSchema: validates against a static schema and applies its declared default values.
  • WithJSONSchemaFunc: same as WithJSONSchema, but the schema bytes are returned by a callback that receives the current values map. Use this when the schema depends on a value inside the config (e.g. an apiVersion field).
  • WithTransform: arbitrary map mutation step.
  • WithEnvSubst: convenience transform that expands ${VAR} placeholders using a Resolver. Compose multiple sources with Resolver.Or (first match wins; see "Resolver vs Source precedence" below).
  • WithEnvSubstFunc: same as WithEnvSubst, but the Resolver is built by a callback at Load time. Use this when the resolver depends on a value already loaded from a source (e.g. a .env file path in the config file).
  • WithValidator: read-only check that may return an error.

Because steps run in registration order, you can interleave them freely. A common pattern is two-phase validation: validate partial data before substitution, substitute, then validate the final form.

Example: dynamic schema selection based on apiVersion:

cfg := synthra.MustNew(
    synthra.WithFile("manifest.yaml"),
    synthra.WithJSONSchemaFunc(func(ctx 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)
    }),
)

Example: two-phase validation (validate before and after substitution):

cfg := synthra.MustNew(
    synthra.WithFile("manifest.yaml"),
    // Step 1: validate the raw "environments" block before substitution.
    synthra.WithJSONSchemaFunc(environmentsSchema),
    // Step 2: expand ${VAR} placeholders.
    synthra.WithEnvSubst(synthra.FromEnv()),
    // Step 3: validate the fully-substituted manifest.
    synthra.WithJSONSchemaFunc(manifestSchema),
)

Multiple WithJSONSchema and WithJSONSchemaFunc calls are fully supported and each adds an independent schema step at the point it was registered. There is no mutual-exclusivity restriction.

Resolver vs Source precedence

Synthra uses two different precedence rules, one for each kind of operation:

  • Sources (tree merge): later wins. When you call WithFile, WithEnv, or WithSource multiple times, each call layers on top of the previous one. The last source to provide a key wins. This matches every major config library (viper, koanf, dynaconf, Figment .merge).

  • Resolvers (per-key lookup): first wins. Resolver.Or tries the receiver before each fallback, returning as soon as one reports found=true. This matches stdlib context.Value, where the innermost (highest-priority) context shadows outer ones, and other per-key lookup chains (Spring PropertySource, os/exec.LookPath).

These rules are different because the operations are different: overlaying a full tree is not the same as looking up one key in a chain of stores. Each rule is named for its operation so you do not need to remember which library uses which: Sources layer in registration order (last wins), Resolvers fall through in call order (first wins via Or).

WithEnv and WithEnvSubst solve different problems and work well together:

  • WithEnv is a source. It reads environment variables and adds them to the config map. For example, APP_SERVER_PORT=8080 becomes server.port.
  • WithEnvSubst is a transform. It expands ${VAR} placeholders that are already present in string values loaded from files or other sources.

Example: three-layer priority with Or (highest priority first):

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

cfg := 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 in the environment, port becomes "9090".

Example: custom transform to normalize values before schema validation:

cfg := 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
)

Validate using custom functions:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
        if port := c.IntOr("port", 0); port < 1 {
            return fmt.Errorf("invalid port: %d", port)
        }
        return nil
    }),
)

To accumulate multiple validation errors in one pass, use errors.Join inside a single WithValidator callback:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    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...)
    }),
)

Error paths for pipeline failures follow the pattern "step[N]:kind" where N is the zero-based step index and kind is "schema", "transform", or "validator".

Pipeline callbacks and Configuration/Configurable

Synthra's pipeline callbacks receive either a *Configurable or a *Configuration, depending on the callback type:

Configurable embeds Configuration, so all read methods are available on both types. All methods are case-insensitive; dot notation addresses nested maps.

All pipeline callbacks receive a context.Context as the first argument, enabling cancellation, timeouts, and tracing across transforms, validators, env-subst resolvers, and schema selectors.

Reading:

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

Writing (only on *Configurable):

v.Set("metadata.region", "eu-west-1")  // creates intermediate maps
v.Delete("debug.experimental")

Walking the tree (only on *Configurable):

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

Array-of-object navigation

When a configuration key holds a slice of objects (e.g. an "environments" array), use the following methods to avoid `.([]any)` / `.(map[string]any)` type assertions in user code. All four methods are nil-safe.

  • *Configuration.SliceLen: returns the length of the slice at path (0 if missing or non-slice).
  • *Configuration.EachMap: returns an iterator over map elements; non-map elements in the slice are silently skipped.
  • *Configuration.Find: returns the first map element where a named field equals a match string (case-insensitive via StringOr).
  • *Configuration.FindFunc: returns the first map element for which a predicate returns true; short-circuits as soon as a match is found.

*Configurable shadows EachMap, Find, and FindFunc to return *Configurable wrappers instead of *Configuration. Mutations on an element returned by Find or iterated by EachMap reach back into the parent's underlying map.

Example: pick an environment by name and build a resolver:

selected := v.Find("environments", "name", envName)
if selected == nil {
    return nil, fmt.Errorf("environment %q not found", envName)
}
cascade, err := synthra.CoalesceEnvFile(".env."+selected.StringOr("name", ""), ".env")
if err != nil {
    return nil, err
}

WithTransform, WithValidator, WithEnvSubstFunc, and WithJSONSchemaFunc run at the map stage, before binding, on the merged *Configurable or *Configuration.

OnBound is a binding-scoped option that goes inside WithBinding. It runs at the binding stage: after the bound struct is decoded and defaults applied, but before its `Validate()` method (if it implements Validator). The type parameter is inferred from the closure, and the compiler enforces that it matches the binding target.

Example combining both stages:

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 is a sub-option of WithBinding, 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
)

*Configurable is not safe for concurrent use. Each Load creates its own; do not share one across goroutines. Only one WithBinding per Synthra instance is supported.

Accessing Configuration Values

Type-specific methods return (value, error). Missing keys and failed coercions are errors; use errors.Is with ErrKeyNotFound or ErrNilConfig as needed. Methods on a nil *Synthra return ErrNilConfig.

// Basic types (strict)
port, err := cfg.Int("server.port")
if err != nil {
    return err
}
host, err := cfg.String("server.host")
if err != nil {
    return err
}
debug, err := cfg.Bool("debug")
if err != nil {
    return err
}
rate, err := cfg.Float64("rate")
if err != nil {
    return err
}

// Optional keys with defaults (no error when missing)
host := cfg.StringOr("server.host", "localhost")
port := cfg.IntOr("server.port", 8080)

// Collections (strict)
tags, err := cfg.StringSlice("tags")
if err != nil {
    return err
}
ports, err := cfg.IntSlice("ports")
if err != nil {
    return err
}
metadata, err := cfg.StringMap("metadata")
if err != nil {
    return err
}

// Time-related (strict)
timeout, err := cfg.Duration("timeout")
if err != nil {
    return err
}
startTime, err := cfg.Time("start_time")
if err != nil {
    return err
}

Generic Get for typed reads (same missing-key errors; primitive coercion matches GetOr for unsupported kinds):

port, err := synthra.Get[int](cfg, "server.port")
if err != nil {
    log.Fatalf("port configuration required: %v", err)
}

Configuration Dumping

Save the current configuration to a file:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithFileDumper("output.yaml"),
)

cfg.Load(context.Background())
cfg.Dump(context.Background())  // Writes to output.yaml

Thread Safety

Synthra is safe for concurrent use by multiple goroutines. Configuration loading and reading are protected by internal locks. Multiple goroutines can safely call Load() and access configuration values simultaneously.

Escape hatches

For debugging or custom serialization, *Synthra.Values returns a shallow copy of the merged top-level map. Nested maps, slices, and pointers are not deep-copied; do not mutate nested values. Treat the result as read-only. To get the live configuration as a *Configuration, use Synthra.Configuration.

Error Handling

Construction failures, load/dump failures, and accessor type-conversion failures are returned as *ConfigError, shaped like os.PathError: Op names the entrypoint (OpNew, OpLoad, OpDump, or OpGet); Path is a diagnostic locator whose meaning depends on Op; Err is the cause for errors.Unwrap, errors.Is, and errors.As.

Use errors.As to inspect the structured error and switch on Op:

if err := cfg.Load(ctx); err != nil {
    var ce *synthra.ConfigError
    if errors.As(err, &ce) {
        switch ce.Op {
        case synthra.OpLoad:
            log.Error("load failed", "path", ce.Path, "err", ce.Err)
        }
    }
    return err
}

Use errors.Is for fixed outcomes such as a missing key, nil receiver, or nil context:

_, err := cfg.Int("server.port")
if errors.Is(err, synthra.ErrKeyNotFound) {
    return useDefaultPort()
}

New may return errors.Join of multiple *ConfigError values. A single errors.As finds the first in the tree; to log every construction error, iterate using the errors.Join unwrap slice (see errors.Join).

Examples

See the examples directory for complete working examples demonstrating various configuration patterns and use cases including:

  • examples/basic: YAML file and struct binding
  • examples/webapp: YAML defaults, env overrides, binding, and Validate
  • examples/testing: synthratest helpers and source.NewMap
  • examples/schema: WithJSONSchema defaults and patternProperties
  • examples/casing: case-insensitive merge and schema as casing authority
  • examples/hooks: WithTransform, WithValidator, and OnBound[T] in one pipeline
  • examples/codecs: WithFileAs (JSON, TOML) and WithFileDumperAs (YAML dump)
  • examples/envsubst-layered: three-layer Resolver.Or precedence
  • examples/multi-schema: two-phase validation with WithJSONSchemaFunc and EachMap
  • examples/consul: optional Consul source with WithIf

For more details, see the package documentation at https://pkg.go.dev/gopherly.dev/synthra

Example

Example demonstrates basic configuration usage.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func exampleInt(cfg *synthra.Synthra, key string) int {
	v, err := cfg.Int(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	// Create config with YAML content
	yamlContent := []byte(`
server:
  host: localhost
  port: 8080
database:
  name: mydb
`)

	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
	)
	if err != nil {
		log.Fatal(err)
	}

	// Load configuration
	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	// Access values
	fmt.Println(exampleString(cfg, "server.host"))
	fmt.Println(exampleInt(cfg, "server.port"))
	fmt.Println(exampleString(cfg, "database.name"))

}
Output:
localhost
8080
mydb
Example (EnvironmentVariables)

Example_environmentVariables demonstrates loading configuration from environment variables.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
)

func main() {
	// In real usage, set environment variables like:
	// export APP_SERVER_HOST=localhost
	// export APP_SERVER_PORT=8080

	cfg, err := synthra.New(
		synthra.WithEnv("APP_"),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	// Access environment variables without the prefix
	// e.g., APP_SERVER_HOST becomes server.host
	fmt.Println("Environment variables loaded")
}
Output:
Environment variables loaded
Example (MultipleSources)

Example_multipleSources demonstrates merging multiple configuration sources.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func exampleInt(cfg *synthra.Synthra, key string) int {
	v, err := cfg.Int(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	// Base configuration
	baseConfig := []byte(`
server:
  host: localhost
  port: 8080
`)

	// Override configuration
	overrideConfig := []byte(`
server:
  port: 9090
`)

	cfg, err := synthra.New(
		synthra.WithContent(baseConfig, codec.YAML),
		synthra.WithContent(overrideConfig, codec.YAML),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	// Later sources override earlier ones
	fmt.Println(exampleString(cfg, "server.host"))
	fmt.Println(exampleInt(cfg, "server.port"))
}
Output:
localhost
9090

Index

Examples

Constants

View Source
const (
	OpNew  = "new"
	OpLoad = "load"
	OpDump = "dump"
	OpGet  = "get"
)

Op values identify which Synthra entrypoint produced a ConfigError. They follow the lowercase convention used by os.PathError.Op, net.OpError.Op, and net/url.Error.Op.

Variables

View Source
var ErrKeyNotFound = errors.New("synthra: key not found")

ErrKeyNotFound is returned when a configuration key is missing or cannot be resolved for strict accessors. Errors may wrap this value; use errors.Is to detect it.

View Source
var ErrNilConfig = errors.New("synthra: nil Synthra")

ErrNilConfig is returned when a typed accessor or Get is used on a nil *Synthra.

View Source
var ErrNilContext = errors.New("synthra: nil context")

ErrNilContext is returned when Synthra.Load or Synthra.Dump is called with a nil context.Context.

Functions

func Get

func Get[T any](c *Synthra, key string) (T, error)

Get returns the value associated with the given key as type T. If c is nil, it returns ErrNilConfig. If the key is missing or empty, or the value cannot be converted to T, it returns an error.

Example:

port, err := synthra.Get[int](cfg, "server.port")
if err != nil {
    return fmt.Errorf("server.port: %w", err)
}

timeout, err := synthra.Get[time.Duration](cfg, "timeout")
if err != nil {
    return err
}

func GetOr

func GetOr[T any](c *Synthra, key string, defaultVal T) T

GetOr returns the value associated with the given key as type T. If the key is not found or cannot be converted to type T, it returns the provided default value. The type T is inferred from the default value.

Example:

port := synthra.GetOr(cfg, "server.port", 8080)           // type inferred as int
host := synthra.GetOr(cfg, "server.host", "localhost")    // type inferred as string
timeout := synthra.GetOr(cfg, "timeout", 30*time.Second)  // type inferred as time.Duration

Types

type BindingOption added in v0.9.0

type BindingOption[T any] interface {
	// contains filtered or unexported methods
}

BindingOption is the constraint type for options that may appear inside WithBinding. The T parameter ties each option to the bound target type so the compiler enforces alignment between the target and any hook.

T appears in the [bindingConfig] parameter of applyToBinding so that two BindingOption instantiations with different T are distinct interfaces at compile time, not just at the type-checker. Without that, a method set independent of T would let BindingOption[App] satisfy BindingOption[Server] silently and the type-mismatch check would only happen at Load time.

func OnBound added in v0.9.0

func OnBound[T any](fn func(*T) error) BindingOption[T]

OnBound registers a function that runs against the bound struct after decode and applyDefaults, but before [Validator.Validate].

Use OnBound for type-safe normalization — lowercasing log levels, computing derived fields, applying region-based defaults. For map-level work before binding, use WithTransform.

The function must not be nil. Multiple OnBound hooks run in registration order; the first error stops the pipeline.

type ConfigError

type ConfigError struct {
	Op   string
	Path string
	Err  error
}

ConfigError is the structured error returned by Synthra for construction, load, dump, and type conversion failures at accessors.

Its shape follows os.PathError: Op names the operation, Path locates the failure in a way that depends on Op (see package docs), and Err is the underlying cause for errors.Unwrap, errors.Is, and errors.As.

Path is diagnostic text only; its format is not a stable API contract. Callers should branch on Op and use errors.Is on Err for specific reasons, not parse Path or ConfigError.Error output.

func NewConfigError

func NewConfigError(op, path string, err error) *ConfigError

NewConfigError returns a *ConfigError. Op should be one of OpNew, OpLoad, OpDump, or OpGet. Path is the polymorphic locator described on ConfigError; use "" when none applies (for example nil-context errors).

func (*ConfigError) Error

func (e *ConfigError) Error() string

Error implements [error]. The format is pinned for tests:

synthra: <Op>[ <Path>]: <Err>

When Path is empty, the space before Path is omitted.

func (*ConfigError) Unwrap

func (e *ConfigError) Unwrap() error

Unwrap returns the underlying error for errors.Is and errors.As.

type Configurable added in v0.12.0

type Configurable struct {
	Configuration
}

Configurable wraps the merged configuration map for pipeline callbacks. It embeds Configuration for all read operations and adds mutation methods (Set, Delete, Walk). Every read method is case-insensitive at each path segment. Dot notation addresses nested maps.

Configurable is not safe for concurrent use. Each Load call creates a fresh instance for the duration of the pipeline.

Write methods on an element returned by Configurable.Find or iterated by Configurable.EachMap share storage with the parent: mutations reach back into the parent's map. Document this at call sites when it matters.

func (*Configurable) Delete added in v0.12.0

func (c *Configurable) Delete(path string) bool

Delete removes the value at path. Reports whether something was removed.

func (*Configurable) EachMap added in v0.12.0

func (c *Configurable) EachMap(path string) iter.Seq2[int, *Configurable]

EachMap shadows Configuration.EachMap to yield mutable *Configurable wrappers instead of read-only *Configuration values. Each wrapper shares the underlying map, so Set and Delete on an element mutate the parent tree.

func (*Configurable) Find added in v0.12.0

func (c *Configurable) Find(path, field, match string) *Configurable

Find shadows Configuration.Find to return a mutable *Configurable for the first element at path where field equals match (case-insensitive). Returns nil if no element matches.

func (*Configurable) FindFunc added in v0.12.0

func (c *Configurable) FindFunc(path string, pred func(*Configurable) bool) *Configurable

FindFunc shadows Configuration.FindFunc to yield a mutable *Configurable wrapper for the matching element. Returns nil if no element matches.

func (*Configurable) Raw added in v0.12.0

func (c *Configurable) Raw() map[string]any

Raw returns the underlying map directly (no clone), enabling in-place mutation. For a safe read-only copy, call c.Configuration.Raw() or use Configuration.Raw on the embedded field.

func (*Configurable) Set added in v0.12.0

func (c *Configurable) Set(path string, value any) error

Set assigns value at path. Intermediate maps are created as needed. Returns an error if a non-final segment along the path exists but is not a map.

func (*Configurable) Walk added in v0.12.0

func (c *Configurable) Walk(fn func(path string, value any) (any, bool))

Walk visits every node in the tree depth-first. The callback receives the dot-path to the node and its current value. Returning (newValue, true) replaces the node; returning (_, false) leaves it unchanged. Slice indices appear as "key[N]" in the path.

type Configuration added in v0.12.0

type Configuration struct {
	// contains filtered or unexported fields
}

Configuration is the public, immutable read view of the merged configuration. It is returned by Synthra.Configuration and also embedded inside the mutable Configurable that pipeline callbacks receive.

All accessor methods are case-insensitive and use dot-notation for nested paths. Every *Or method is nil-safe: calling it on a nil *Configuration returns the supplied default rather than panicking. Strict (non-Or) accessors return ErrNilConfig on a nil receiver.

func (*Configuration) Bool added in v0.12.0

func (c *Configuration) Bool(path string) (bool, error)

Bool returns the value at path as a bool.

func (*Configuration) BoolOr added in v0.12.0

func (c *Configuration) BoolOr(path string, def bool) bool

BoolOr returns the value at path as a bool, or def if not found or nil receiver.

func (*Configuration) Duration added in v0.12.0

func (c *Configuration) Duration(path string) (time.Duration, error)

Duration returns the value at path as a time.Duration.

func (*Configuration) DurationOr added in v0.12.0

func (c *Configuration) DurationOr(path string, def time.Duration) time.Duration

DurationOr returns the value at path as a time.Duration, or def if not found or nil receiver.

func (*Configuration) EachMap added in v0.12.0

func (c *Configuration) EachMap(path string) iter.Seq2[int, *Configuration]

EachMap returns an iterator over map elements at path. Non-map elements in the slice are skipped. Missing path or non-slice values yield nothing. The returned Configuration shares the underlying map: mutations through a Configurable wrapper (see Configurable.EachMap) reach back to the parent.

func (*Configuration) Find added in v0.12.0

func (c *Configuration) Find(path, field, match string) *Configuration

Find returns the first map element at path where field equals match (case-insensitive). Returns nil if no element matches.

func (*Configuration) FindFunc added in v0.12.0

func (c *Configuration) FindFunc(path string, pred func(*Configuration) bool) *Configuration

FindFunc returns the first map element at path for which pred returns true, or nil if no element matches. Non-map elements in the slice are skipped. Because the returned *Configuration shares the underlying map, nil-safe *Or methods on it compose cleanly without guards.

func (*Configuration) Float64 added in v0.12.0

func (c *Configuration) Float64(path string) (float64, error)

Float64 returns the value at path as a float64.

func (*Configuration) Float64Or added in v0.12.0

func (c *Configuration) Float64Or(path string, def float64) float64

Float64Or returns the value at path as a float64, or def if not found or nil receiver.

func (*Configuration) Get added in v0.12.0

func (c *Configuration) Get(path string) any

Get returns the raw value at path or nil if no key matches.

func (*Configuration) Has added in v0.12.0

func (c *Configuration) Has(path string) bool

Has reports whether path exists in the configuration.

func (*Configuration) Int added in v0.12.0

func (c *Configuration) Int(path string) (int, error)

Int returns the value at path as an int.

func (*Configuration) Int64 added in v0.12.0

func (c *Configuration) Int64(path string) (int64, error)

Int64 returns the value at path as an int64.

func (*Configuration) Int64Or added in v0.12.0

func (c *Configuration) Int64Or(path string, def int64) int64

Int64Or returns the value at path as an int64, or def if not found or nil receiver.

func (*Configuration) IntOr added in v0.12.0

func (c *Configuration) IntOr(path string, def int) int

IntOr returns the value at path as an int, or def if not found or nil receiver.

func (*Configuration) IntSlice added in v0.12.0

func (c *Configuration) IntSlice(path string) ([]int, error)

IntSlice returns the value at path as a []int.

func (*Configuration) IntSliceOr added in v0.12.0

func (c *Configuration) IntSliceOr(path string, def []int) []int

IntSliceOr returns the value at path as a []int, or def if not found or nil receiver.

func (*Configuration) Keys added in v0.12.0

func (c *Configuration) Keys() []string

Keys returns the top-level keys in their stored casing.

func (*Configuration) Raw added in v0.12.0

func (c *Configuration) Raw() map[string]any

Raw returns a shallow copy of the underlying map.

func (*Configuration) SliceLen added in v0.12.0

func (c *Configuration) SliceLen(path string) int

SliceLen returns the length of the slice at path. Returns 0 if the path is missing, the value is not a slice, or the receiver is nil.

func (*Configuration) String added in v0.12.0

func (c *Configuration) String(path string) (string, error)

String returns the value at path as a string.

func (*Configuration) StringMap added in v0.12.0

func (c *Configuration) StringMap(path string) (map[string]any, error)

StringMap returns the value at path as a map[string]any.

func (*Configuration) StringMapOr added in v0.12.0

func (c *Configuration) StringMapOr(path string, def map[string]any) map[string]any

StringMapOr returns the value at path as a map[string]any, or def if not found or nil receiver.

func (*Configuration) StringMapString added in v0.12.0

func (c *Configuration) StringMapString(path string) (map[string]string, error)

StringMapString returns the value at path as a map[string]string.

func (*Configuration) StringOr added in v0.12.0

func (c *Configuration) StringOr(path, def string) string

StringOr returns the value at path as a string, or def if not found or nil receiver.

func (*Configuration) StringSlice added in v0.12.0

func (c *Configuration) StringSlice(path string) ([]string, error)

StringSlice returns the value at path as a []string.

func (*Configuration) StringSliceOr added in v0.12.0

func (c *Configuration) StringSliceOr(path string, def []string) []string

StringSliceOr returns the value at path as a []string, or def if not found or nil receiver.

func (*Configuration) Time added in v0.12.0

func (c *Configuration) Time(path string) (time.Time, error)

Time returns the value at path as a time.Time.

func (*Configuration) TimeOr added in v0.12.0

func (c *Configuration) TimeOr(path string, def time.Time) time.Time

TimeOr returns the value at path as a time.Time, or def if not found or nil receiver.

type Dumper

type Dumper interface {
	// Dump writes the configuration values to a destination.
	// The values map should not be modified by implementations.
	Dump(ctx context.Context, values map[string]any) error
}

Dumper defines the interface for configuration dumpers. Implementations write configuration data to various destinations such as files or remote services.

Dump must be safe to call concurrently.

type Option

type Option func(cfg *config)

Option is a functional option that can be used to configure a Synthra instance. Options apply to an internal config struct; the constructor validates and builds the public Synthra from it. Options must not be nil; passing nil results in a validation error at construction.

func WithBinding

func WithBinding[T any](target *T, opts ...BindingOption[T]) Option

WithBinding registers target as the destination for struct decoding and accepts binding-scoped options. T is inferred from target; every option passed must share the same T, which the compiler enforces.

Example:

type Config struct {
    Server struct {
        Host string `synthra:"host"`
        Port int    `synthra:"port"`
    } `synthra:"server"`
}

var appCfg Config
cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithBinding(&appCfg,
        synthra.OnBound(func(c *Config) error {
            c.Server.Host = strings.ToLower(c.Server.Host)
            return nil
        }),
    ),
)
fmt.Println(appCfg.Server.Port) // populated from config
Example

ExampleWithBinding demonstrates binding configuration to a struct.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func main() {
	type ServerConfig struct {
		Host string `synthra:"host"`
		Port int    `synthra:"port"`
	}

	type AppConfig struct {
		Server ServerConfig `synthra:"server"`
	}

	yamlContent := []byte(`
server:
  host: localhost
  port: 8080
`)

	var appConfig AppConfig
	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
		synthra.WithBinding(&appConfig),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s:%d\n", appConfig.Server.Host, appConfig.Server.Port)
}
Output:
localhost:8080

func WithConsul

func WithConsul(path string) Option

WithConsul returns an Option that configures the Synthra instance to load configuration data from a Consul server. The format is automatically detected from the path extension. For custom formats, use WithConsulAs instead.

CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a validation error at construction. For conditional Consul (e.g., development without Consul), wrap this option with WithIf.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${APP_ENV}/service.yaml" expands to "production/service.yaml" when APP_ENV=production

Required environment variables:

  • CONSUL_HTTP_ADDR: The address of the Consul server (e.g., "http://localhost:8500")
  • CONSUL_HTTP_TOKEN: The access token for authentication with Consul (optional)

Example:

cfg := synthra.MustNew(
    synthra.WithConsul("production/service.yaml"),  // Fails at construction if CONSUL_HTTP_ADDR is unset
)

func WithConsulAs

func WithConsulAs(path string, decoder codec.Decoder) Option

WithConsulAs returns an Option that configures the Synthra instance to load configuration data from a Consul server with explicit decoder. Use this when you need to override the format detection.

CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a validation error at construction. For conditional Consul (e.g., development without Consul), wrap this option with WithIf.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${APP_ENV}/service" expands to "production/service" when APP_ENV=production

Required environment variables:

  • CONSUL_HTTP_ADDR: The address of the Consul server (e.g., "http://localhost:8500")
  • CONSUL_HTTP_TOKEN: The access token for authentication with Consul (optional)

Example:

cfg := synthra.MustNew(
    synthra.WithConsulAs("production/service", codec.JSON),
)

func WithContent

func WithContent(data []byte, decoder codec.Decoder) Option

WithContent returns an Option that configures the Synthra instance to load configuration data from a byte slice. The decoder parameter specifies how to decode the data (e.g., codec.YAML, codec.JSON).

Example:

yamlContent := []byte("server:\n  port: 8080")
cfg := synthra.MustNew(
    synthra.WithContent(yamlContent, codec.YAML),
)
Example

ExampleWithContent demonstrates loading configuration from byte content.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	jsonContent := []byte(`{
		"app": {
			"name": "MyApp",
			"version": "1.0.0"
		}
	}`)

	cfg, err := synthra.New(
		synthra.WithContent(jsonContent, codec.JSON),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(exampleString(cfg, "app.name"))
	fmt.Println(exampleString(cfg, "app.version"))
}
Output:
MyApp
1.0.0

func WithDumper

func WithDumper(d Dumper) Option

WithDumper adds a custom Dumper to the configuration dumper. Use it to plug in dumpers not covered by the built-in options (e.g. a database, remote API, or custom file format). The dumper must not be nil.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithDumper(myCustomDumper),
)

func WithEnv

func WithEnv(prefix string) Option

WithEnv returns an Option that configures the Synthra instance to load configuration data from environment variables. The prefix parameter specifies the prefix for the environment variables to be loaded. Environment variables are converted to lowercase and underscores create nested structures.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnv("APP_"),  // Loads APP_SERVER_PORT as server.port
)

func WithEnvSubst added in v0.6.0

func WithEnvSubst(r Resolver) Option

WithEnvSubst registers a transform that expands POSIX-style ${VAR} placeholders in all string values of the merged configuration map.

The resolver argument must not be nil. To consult multiple sources, compose them with Resolver.Or — the first Resolver that reports found wins (highest priority first):

synthra.FromEnv().Prefix("APP_").Or(envFile).Or(synthra.FromMap(defaults))

Supported syntax includes ${VAR}, ${VAR:-default}, ${VAR:=default}, ${VAR^^}, ${VAR#pattern}, and more. The full set is documented at https://pkg.go.dev/github.com/fluxcd/pkg/envsubst.

This is different from WithEnv. WithEnv is a source: it reads environment variables and adds them as configuration keys. For example, APP_SERVER_PORT=8080 becomes server.port in the config map. WithEnvSubst is a transform: it expands ${VAR} placeholders that appear inside string values already loaded from other sources. Both can be used together without overlap.

Example — OS environment only:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv()),
)

Example — expand placeholders using a static map:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromMap(map[string]string{
        "ENV":  "production",
        "PORT": "8080",
    })),
)
// If config.yaml contains: host: "app-${ENV}.example.com"
// After Load: cfg.Get("host") => "app-production.example.com"

Example — three-layer priority with Or (highest priority first):

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

cfg := 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 wins.
// If neither is set, the ${VAR:-default} fallback gives "3000".

func WithEnvSubstFunc added in v0.8.0

func WithEnvSubstFunc(fn func(context.Context, *Configurable) (Resolver, error)) Option

WithEnvSubstFunc expands ${VAR} placeholders using a Resolver that is determined dynamically at Load time. The callback receives the current context.Context and *Configurable and returns a Resolver (or an error that stops the pipeline).

This follows the same pattern as WithJSONSchemaFunc: the Func suffix means "the input to this step is determined at Load time from the current values." Use this when the resolver depends on values that are only known after sources are merged — for example, a .env file path that is itself stored in the config file.

The function must not be nil.

Example — .env file path specified in config:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubstFunc(func(ctx context.Context, v *synthra.Configurable) (synthra.Resolver, error) {
        envPath := v.StringOr("envfile", "")
        if envPath == "" {
            return synthra.FromEnv(), nil
        }
        return synthra.FromEnvFile(envPath)
    }),
)

Example — Vault resolver with setup that may fail:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubstFunc(func(ctx context.Context, _ *synthra.Configurable) (synthra.Resolver, error) {
        client, err := vault.NewClient(vault.DefaultConfig())
        if err != nil {
            return nil, fmt.Errorf("vault client: %w", err)
        }
        return func(name string) (string, bool) {
            secret, err := client.Read("secret/data/" + name)
            if err != nil || secret == nil {
                return "", false
            }
            v, ok := secret.Data["value"].(string)
            return v, ok
        }, nil
    }),
)

func WithFile

func WithFile(path string) Option

WithFile returns an Option that configures the Synthra instance to load configuration data from a file. The format is automatically detected from the file extension (.yaml, .yml, .json, .toml). For files without extensions or custom formats, use WithFileAs instead.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${CONFIG_DIR}/app.yaml" expands to "/etc/myapp/app.yaml" when CONFIG_DIR=/etc/myapp

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),     // Automatically detects YAML
    synthra.WithFile("override.json"),   // Automatically detects JSON
)
Example

ExampleWithFile demonstrates loading configuration from a file.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	// Create a temporary config file (in real code, use an actual file path)
	cfg, err := synthra.New(
		synthra.WithContent([]byte(`{"name": "example"}`), codec.JSON),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(exampleString(cfg, "name"))
}
Output:
example

func WithFileAs

func WithFileAs(path string, decoder codec.Decoder) Option

WithFileAs returns an Option that configures the Synthra instance to load configuration data from a file with explicit decoder. Use this when the file doesn't have an extension or when you need to override the format detection.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${CONFIG_DIR}/app" expands to "/etc/myapp/app" when CONFIG_DIR=/etc/myapp

Example:

cfg := synthra.MustNew(
    synthra.WithFileAs("config", codec.YAML),      // No extension, specify YAML
    synthra.WithFileAs("config.dat", codec.JSON),  // Wrong extension, specify JSON
)

func WithFileDumper

func WithFileDumper(path string) Option

WithFileDumper returns an Option that configures the Synthra instance to dump configuration data to a file. The format is automatically detected from the file extension (.yaml, .yml, .json, .toml). For files without extensions or custom formats, use WithFileDumperAs instead.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${LOG_DIR}/config.yaml" expands to "/var/log/config.yaml" when LOG_DIR=/var/log

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithFileDumper("output.yaml"),  // Auto-detects YAML
)

func WithFileDumperAs

func WithFileDumperAs(path string, encoder codec.Encoder) Option

WithFileDumperAs returns an Option that configures the Synthra instance to dump configuration data to a file with explicit encoder. Use this when the file doesn't have an extension or when you need to override the format detection.

Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${OUTPUT_DIR}/config" expands to "/tmp/config" when OUTPUT_DIR=/tmp

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithFileDumperAs("output", codec.YAML),  // No extension, specify YAML
)

func WithFileFS

func WithFileFS(fsys fs.FS, path string) Option

WithFileFS returns an Option that loads configuration from path inside fsys. The format is detected from path's file extension, like WithFile. Paths support environment variable expansion using ${VAR} or $VAR syntax.

If fsys is nil, New returns a validation error at construction.

Example (tests with testing/fstest.MapFS):

fsys := fstest.MapFS{"app.yaml": &fstest.MapFile{Data: []byte("port: 8080\n")}}
cfg := synthra.MustNew(synthra.WithFileFS(fsys, "app.yaml"))

func WithFileFSAs

func WithFileFSAs(fsys fs.FS, path string, decoder codec.Decoder) Option

WithFileFSAs returns an Option that loads configuration from path inside fsys using an explicit decoder. It combines WithFileFS (embedded filesystem) with WithFileAs (explicit decoder) for files that have no extension or need a format override.

Paths support environment variable expansion using ${VAR} or $VAR syntax. If fsys is nil, New returns a validation error.

Example:

//go:embed configs
var configFS embed.FS

cfg := synthra.MustNew(
    synthra.WithFileFSAs(configFS, "configs/app", codec.YAML),
)

func WithIf

func WithIf(condition bool, opts ...Option) Option

WithIf returns an Option that applies the provided options only when condition is true. When condition is false, this option is a no-op.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
        synthra.WithConsul("production/service.yaml"),
    ),
)

func WithJSONSchema

func WithJSONSchema(schema []byte) Option

WithJSONSchema adds a static JSON Schema as a pipeline step. The schema is used to apply default values and validate the configuration at the point in the pipeline where this option was registered.

Synthra supports JSON Schema drafts 4, 6, 7, 2019-09, and 2020-12.

Automatic defaults

Synthra extracts every "default" value declared in the schema and applies it to any key that is missing from the current values map. Defaults are applied before validation, so the schema validator always sees a fully populated map.

Defaults are applied recursively at every level:

  • "properties" — fills missing fixed-name keys in an object
  • "patternProperties" — fills missing keys inside every existing map entry whose name matches the regular-expression pattern
  • "items" — fills missing keys inside each element of an array

User-provided values are never overridden; only absent keys are filled.

Validation

After defaults are applied the values are validated against the schema. If validation fails, Load returns a *ConfigError with Op OpLoad and Path "step[N]:schema" where N is the zero-based index of this step in the registered pipeline.

Multiple WithJSONSchema calls are allowed and each adds an independent pipeline step that runs at the point it was registered.

Example:

schema := []byte(`{
    "type": "object",
    "required": ["service"],
    "properties": {
        "service":   {"type": "string"},
        "port":      {"type": "integer", "default": 8080},
        "log_level": {"type": "string",  "default": "info",
                      "enum": ["debug","info","warn","error"]}
    }
}`)

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithJSONSchema(schema),
)
Example

ExampleWithJSONSchema validates the merged configuration against a JSON Schema. Load fails fast if required keys are missing or values have the wrong type.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func exampleInt(cfg *synthra.Synthra, key string) int {
	v, err := cfg.Int(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	schema := []byte(`{
		"type": "object",
		"required": ["service", "port"],
		"properties": {
			"service": {"type": "string", "minLength": 1},
			"port":    {"type": "integer", "minimum": 1, "maximum": 65535}
		}
	}`)

	cfg := synthra.MustNew(
		synthra.WithContent([]byte("service: api\nport: 8080\n"), codec.YAML),
		synthra.WithJSONSchema(schema),
	)
	if err := cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("service=%s port=%d\n", exampleString(cfg, "service"), exampleInt(cfg, "port"))
}
Output:
service=api port=8080

func WithJSONSchemaFunc added in v0.8.0

func WithJSONSchemaFunc(selector func(context.Context, *Configurable) ([]byte, error)) Option

WithJSONSchemaFunc registers a dynamic schema resolver as a pipeline step. The selector is called during Synthra.Load with the current context.Context and *Configurable and returns the JSON Schema bytes to use at that point in the pipeline. This enables the schema to be chosen based on a value already present in the config — for example an apiVersion field — without requiring a two-pass read.

The bytes returned by the selector are compiled and the schema's "default" values are extracted and applied before validation runs, exactly as with WithJSONSchema.

Multiple WithJSONSchemaFunc calls are allowed; each adds an independent schema step that runs at the point it was registered. This is the mechanism for two-phase validation: register a pre-transform schema step to validate partial data, then register a post-transform schema step to validate the final form.

If the selector returns an error, or the returned bytes fail to compile, or validation fails, Load returns a *ConfigError with Op OpLoad and Path "step[N]:schema" where N is the zero-based index of this step.

The selector function must not be nil.

Example — select schema version from the config's own apiVersion key:

cfg := synthra.MustNew(
    synthra.WithFile("deployah.yaml"),
    synthra.WithJSONSchemaFunc(func(ctx context.Context, v *synthra.Configurable) ([]byte, error) {
        version := v.StringOr("apiVersion", "")
        if version == "" {
            return nil, errors.New("apiVersion is required")
        }
        return schema.GetManifestSchema(version)
    }),
)

Example — two-phase validation (validate before and after substitution):

cfg := synthra.MustNew(
    synthra.WithFile("manifest.yaml"),
    synthra.WithJSONSchemaFunc(environmentsSchema), // validate raw environments
    synthra.WithEnvSubst(synthra.FromEnv()),         // substitute variables
    synthra.WithJSONSchemaFunc(manifestSchema),     // validate substituted manifest
)

func WithSource

func WithSource(loader Source) Option

WithSource adds a custom Source to the configuration loader. Use it to plug in sources not covered by the built-in options (e.g. a database, remote API, or custom file format). The source must not be nil.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithSource(myCustomSource),
)

func WithTag

func WithTag(tagName string) Option

WithTag sets a custom struct tag name for binding (default: "synthra"). Use it when the default tag clashes with another convention or you want a shorter key (for example "cfg" or "config").

Example:

type AppConfig struct {
    Port int `cfg:"port"` // Using custom tag
}

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithBinding(&appConfig),
    synthra.WithTag("cfg"),
)

func WithTransform added in v0.2.0

func WithTransform(fn func(context.Context, *Configurable) error) Option

WithTransform registers a function that transforms the configuration values as a pipeline step. The transform runs at the point in the pipeline where it was registered, after any preceding steps have completed.

The function receives the current context.Context and *Configurable and mutates it in place. Returning an error aborts Load with a *ConfigError whose Path identifies the failing step by its index and kind ("step[0]:transform", "step[1]:transform", ...).

Multiple transforms are applied in registration order.

Example — normalize log level to lowercase, then validate with a schema:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithTransform(func(ctx context.Context, v *synthra.Configurable) error {
        if level, err := v.String("log_level"); err == nil {
            return v.Set("log_level", strings.ToLower(level))
        }
        return nil
    }),
    synthra.WithJSONSchema(schema),
)

func WithValidator

func WithValidator(fn func(context.Context, *Configuration) error) Option

WithValidator adds a custom validation function as a pipeline step. It runs a read-only check against the current *Configuration at the point in the pipeline where it was registered. Multiple validators run in registration order; the first error stops the pipeline.

Panics inside the validator are recovered and reported as errors. The function must not be nil.

If the validator returns an error or panics, Load returns a *ConfigError with Op OpLoad and Path "step[N]:validator" where N is the zero-based index of this step.

Use errors.Join inside a single validator to report all failures at once without adding new API.

Example:

cfg, err := synthra.New(
    synthra.WithFile("config.yaml"),
    synthra.WithValidator(func(ctx context.Context, c *synthra.Configuration) error {
        port := c.IntOr("port", 0)
        if port < 1 || port > 65535 {
            return fmt.Errorf("port %d out of range", port)
        }
        return nil
    }),
)
Example

ExampleWithValidator demonstrates using a custom validator.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func main() {
	yamlContent := []byte(`name: myapp`)

	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
		synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
			if !c.Has("name") {
				return fmt.Errorf("name is required")
			}
			return nil
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println("Validation passed")
}
Output:
Validation passed

type Resolver added in v0.8.0

type Resolver func(name string) (value string, found bool)

Resolver looks up a variable by name for ${VAR} expansion. It follows the Go "comma ok" idiom, matching os.LookupEnv: the second return value reports whether the variable was found. A nil Resolver always returns ("", false). A Resolver must be safe for concurrent use.

To consult multiple sources, compose them with Resolver.Or: the first Resolver that reports found wins.

func CoalesceEnvFile added in v0.12.0

func CoalesceEnvFile(paths ...string) (Resolver, error)

CoalesceEnvFile returns a Resolver backed by the first existing path in the list. Missing paths are silently skipped. A parse error on a found file is returned and halts the search. With no paths or all paths missing, a no-op Resolver and nil error are returned.

Follows SQL COALESCE semantics: first non-missing argument wins. To compose the returned Resolver with other sources, use Resolver.Or.

Example — try environment-specific file, then shared .env:

r, err := synthra.CoalesceEnvFile(".env."+env, ".env")
if err != nil {
    log.Fatal(err)
}
cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Or(r)),
)

func FromEnv added in v0.8.0

func FromEnv() Resolver

FromEnv returns a Resolver that reads from the live OS environment using os.LookupEnv. Changes between Synthra.Load calls are visible.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv()),
)

func FromEnvFile added in v0.8.0

func FromEnvFile(path string) (Resolver, error)

FromEnvFile reads a .env file eagerly and returns a map-backed Resolver. The file is read and parsed at call time; if the file does not exist or cannot be parsed, an error is returned.

Parsing is handled by github.com/hashicorp/go-envparse which supports quoted values, comment lines, the export prefix, inline comments, and escape sequences.

Supported syntax:

  • KEY=VALUE (simple assignment)
  • KEY="VALUE" or KEY='VALUE' (quoted values, preserves whitespace)
  • # comment lines (ignored)
  • export KEY=VALUE (export prefix stripped)
  • Empty lines (ignored)

Example:

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Or(envFile)),
)

Example — layered with .env.local override (first match wins via Or):

base, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}
local, err := synthra.FromEnvFile(".env.local")
if err != nil && !os.IsNotExist(err) {
    log.Fatal(err)
}

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Or(local).Or(base)),
)

func FromEnvFileIfExists added in v0.12.0

func FromEnvFileIfExists(path string) (Resolver, error)

FromEnvFileIfExists returns a Resolver backed by the .env file at path. If the file does not exist, a no-op Resolver and nil error are returned. Parse errors on an existing file are returned unchanged.

Use this instead of FromEnvFile when the .env file is optional. To fall back across multiple candidates in order, see CoalesceEnvFile.

Example — OS env takes priority, .env is optional:

r, err := synthra.FromEnvFileIfExists(".env")
if err != nil {
    log.Fatal(err)
}
cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Or(r)),
)

func FromMap added in v0.8.0

func FromMap(m map[string]string) Resolver

FromMap returns a Resolver that looks up variables from a static map. The map is read as-is; keys are case-sensitive. Passing a nil map returns a Resolver that never finds any variable.

Example:

r := synthra.FromMap(map[string]string{
    "ENV":  "production",
    "PORT": "8080",
})

func (Resolver) Or added in v0.9.0

func (r Resolver) Or(fallbacks ...Resolver) Resolver

Or returns a Resolver that tries the receiver first, then each fallback in order, stopping at the first one that reports found. This is the standard Go lookup-chain pattern: the same semantics as context.Value, where the innermost (highest-priority) context shadows outer ones.

Precedence rule: the receiver has highest priority; fallbacks are consulted in the order they are listed. The first Resolver to return found=true wins.

Empty string counts as found. A resolver that returns ("", true) stops the Or chain — no further fallback resolver is consulted. This matches context.Value behavior: a deliberately-set empty value shadows later contexts.

Note: the envsubst library processes ${VAR:-default} according to POSIX, which fires when the variable is unset OR empty. So even if Or stops the chain with ("", true), a template that uses ${VAR:-fallback} will still expand to "fallback". Use bare ${VAR} or ${VAR-fallback} (single dash, unset only) if you need to distinguish unset from explicitly empty.

Nil fallbacks in the list are silently skipped, which makes it safe to pass a conditionally-built resolver without a guard:

r.Or(maybeNilResolver, synthra.FromEnv())

A nil receiver is treated as a Resolver that never finds anything; fallbacks are still consulted in order.

Or() with no arguments returns the receiver unchanged.

Example — three-layer priority (OS env prefix > .env file > static defaults):

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(
        synthra.FromEnv().Prefix("APP_").     // highest: prefixed OS env
            Or(envFile).                       // middle:  .env file
            Or(synthra.FromMap(defaults)),     // lowest:  static defaults
    ),
)

func (Resolver) Prefix added in v0.8.0

func (r Resolver) Prefix(prefix string) Resolver

Prefix returns a Resolver that prepends prefix to every lookup name before delegating to the receiver. Prefix("APP_") resolves "PORT" by looking up "APP_PORT" in the underlying resolver. This works with any Resolver — FromEnv, FromEnvFile, or FromMap.

If prefix is empty, the receiver is returned unchanged.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Prefix("APP_")),
)

Example — prefix applied to a .env file resolver, with OS env as fallback:

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Prefix("APP_").Or(envFile.Prefix("APP_"))),
)

type Source

type Source interface {
	Load(ctx context.Context) (map[string]any, error)
}

Source loads configuration data from a location such as files, environment variables, or remote services.

Load must be safe to call concurrently. Returned keys keep their original casing; case-insensitive lookup is performed by Synthra at access time. Sources should not lowercase or otherwise normalize keys themselves.

type Synthra

type Synthra struct {
	// contains filtered or unexported fields
}

Synthra manages configuration data loaded from multiple sources. It provides thread-safe access to configuration values and supports binding to structs, validation, and dumping to files.

Synthra is the runtime object returned by New/MustNew; use it for Load, typed accessors (String, Int, ...), and Dump. Use Synthra.Configuration to obtain a lock-free, immutable point-in-time view.

Synthra is safe for concurrent use by multiple goroutines.

func MustNew

func MustNew(opts ...Option) *Synthra

MustNew is like New but panics if validation fails. Use it in main() or package-level initialization where a panic is acceptable. For explicit error handling, use New instead.

Example:

cfg := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnv("APP_"),
    synthra.WithBinding(&appCfg),
)
fmt.Println(cfg.Get("server.port"))
Example

ExampleMustNew demonstrates creating a configuration instance with panic on error.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
)

func main() {
	cfg := synthra.MustNew()
	if err := cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println("Synthra created successfully")
}
Output:
Synthra created successfully

func New

func New(opts ...Option) (*Synthra, error)

New creates a new Synthra instance with the provided options. Options are applied in order to an internal config. Validation errors are collected and reported after all options are applied, so callers never receive a partially-initialized instance. A nil option is treated as a validation error.

Use MustNew in main() or initialization code where a panic on error is acceptable.

Example:

cfg, err := synthra.New(
    synthra.WithFile("config.yaml"),
    synthra.WithEnv("APP_"),
    synthra.WithBinding(&appCfg),
)
if err != nil {
    log.Fatal(err)
}
fmt.Println(cfg.Get("server.port"))
Example

ExampleNew demonstrates creating a new configuration instance.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
)

func main() {
	cfg, err := synthra.New()
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println("Synthra created successfully")
}
Output:
Synthra created successfully

func (*Synthra) Bool

func (c *Synthra) Bool(key string) (bool, error)

Bool returns the value at key as a bool.

Example

ExampleSynthra_Bool demonstrates retrieving boolean values.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleBool(cfg *synthra.Synthra, key string) bool {
	v, err := cfg.Bool(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	jsonContent := []byte(`{"debug": true, "verbose": false}`)

	cfg, err := synthra.New(
		synthra.WithContent(jsonContent, codec.JSON),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(exampleBool(cfg, "debug"))
	fmt.Println(exampleBool(cfg, "verbose"))
}
Output:
true
false

func (*Synthra) BoolOr

func (c *Synthra) BoolOr(key string, defaultVal bool) bool

BoolOr returns the value at key as a bool, or the default if not found.

func (*Synthra) Configuration added in v0.12.0

func (c *Synthra) Configuration() *Configuration

Configuration returns the current immutable configuration snapshot. After Load completes successfully, this returns the loaded values. Before Load (or if Load has never been called), it returns an empty configuration.

Configuration is nil-safe: calling it on a nil *Synthra returns an empty configuration.

The returned *Configuration is safe to hold and read from any goroutine without additional synchronization. Successive calls to Load atomically replace the configuration; callers holding an older *Configuration continue to see their point-in-time view.

func (*Synthra) Dump

func (c *Synthra) Dump(ctx context.Context) error

Dump writes the current configuration values to the registered dumpers.

Errors:

Example

ExampleSynthra_Dump demonstrates writing configuration to registered dumpers.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
	"gopherly.dev/synthra/synthratest"
)

func main() {
	// Create a mock dumper for demonstration
	dumper := &synthratest.Dumper{}

	cfg := synthra.MustNew(
		synthra.WithContent([]byte(`{"service": "api", "version": "1.0"}`), codec.JSON),
		synthra.WithDumper(dumper),
	)

	if err := cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	if err := cfg.Dump(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println("Configuration dumped successfully")
}
Output:
Configuration dumped successfully

func (*Synthra) Duration

func (c *Synthra) Duration(key string) (time.Duration, error)

Duration returns the value at key as a time.Duration.

func (*Synthra) DurationOr

func (c *Synthra) DurationOr(key string, defaultVal time.Duration) time.Duration

DurationOr returns the value at key as a time.Duration, or def if not found.

func (*Synthra) Float64

func (c *Synthra) Float64(key string) (float64, error)

Float64 returns the value at key as a float64.

func (*Synthra) Float64Or

func (c *Synthra) Float64Or(key string, defaultVal float64) float64

Float64Or returns the value at key as a float64, or the default if not found.

func (*Synthra) Get

func (c *Synthra) Get(key string) any

Get returns the value associated with the given key as an any type. If the key is not found, it returns nil.

Example

ExampleSynthra_Get demonstrates retrieving configuration values.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func main() {
	yamlContent := []byte(`
settings:
  enabled: true
  count: 42
`)

	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(cfg.Get("settings.enabled"))
	fmt.Println(cfg.Get("settings.count"))
}
Output:
true
42

func (*Synthra) Int

func (c *Synthra) Int(key string) (int, error)

Int returns the value at key as an int.

Example

ExampleSynthra_Int demonstrates retrieving integer values.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleInt(cfg *synthra.Synthra, key string) int {
	v, err := cfg.Int(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	jsonContent := []byte(`{"port": 8080, "workers": 4}`)

	cfg, err := synthra.New(
		synthra.WithContent(jsonContent, codec.JSON),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(exampleInt(cfg, "port"))
	fmt.Println(exampleInt(cfg, "workers"))
}
Output:
8080
4

func (*Synthra) Int64

func (c *Synthra) Int64(key string) (int64, error)

Int64 returns the value at key as an int64.

func (*Synthra) Int64Or

func (c *Synthra) Int64Or(key string, defaultVal int64) int64

Int64Or returns the value at key as an int64, or the default if not found.

func (*Synthra) IntOr

func (c *Synthra) IntOr(key string, defaultVal int) int

IntOr returns the value at key as an int, or the default if not found.

func (*Synthra) IntSlice

func (c *Synthra) IntSlice(key string) ([]int, error)

IntSlice returns the value at key as a []int.

func (*Synthra) IntSliceOr

func (c *Synthra) IntSliceOr(key string, defaultVal []int) []int

IntSliceOr returns the value at key as a []int, or the default if not found.

func (*Synthra) Load

func (c *Synthra) Load(ctx context.Context) error

Load loads configuration data from the registered sources and merges it into the internal values map. The method executes all registered pipeline steps in registration order before atomically updating the internal state. Load is safe to call concurrently.

The pipeline runs in this order:

  1. Load and merge all sources (later sources override earlier ones).
  2. Execute each registered step (WithJSONSchema, WithJSONSchemaFunc, WithTransform, WithEnvSubst, WithValidator) in the order they were registered.
  3. Decode into the bound struct (WithBinding), apply struct-tag defaults, and call the struct's Validate method if it implements Validator.

Errors:

Example

ExampleSynthra_Load demonstrates loading configuration.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func exampleInt(cfg *synthra.Synthra, key string) int {
	v, err := cfg.Int(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	cfg := synthra.MustNew(
		synthra.WithContent([]byte(`{"app": "example", "port": 8080}`), codec.JSON),
	)

	if err := cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	app := exampleString(cfg, "app")
	port := exampleInt(cfg, "port")
	fmt.Printf("App: %s, Port: %d\n", app, port)
}
Output:
App: example, Port: 8080

func (*Synthra) String

func (c *Synthra) String(key string) (string, error)

String returns the value at key as a string.

Example

ExampleSynthra_String demonstrates retrieving string values.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleString(cfg *synthra.Synthra, key string) string {
	v, err := cfg.String(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	jsonContent := []byte(`{"name": "MyApp", "env": "production"}`)

	cfg, err := synthra.New(
		synthra.WithContent(jsonContent, codec.JSON),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	fmt.Println(exampleString(cfg, "name"))
	fmt.Println(exampleString(cfg, "env"))
}
Output:
MyApp
production

func (*Synthra) StringMap

func (c *Synthra) StringMap(key string) (map[string]any, error)

StringMap returns the value at key as a map[string]any.

Example

ExampleSynthra_StringMap demonstrates retrieving string maps.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleStringMap(cfg *synthra.Synthra, key string) map[string]any {
	v, err := cfg.StringMap(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	yamlContent := []byte(`
metadata:
  author: John Doe
  version: 1.0.0
`)

	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	metadata := exampleStringMap(cfg, "metadata")
	fmt.Println(metadata["author"])
	fmt.Println(metadata["version"])
}
Output:
John Doe
1.0.0

func (*Synthra) StringMapOr

func (c *Synthra) StringMapOr(key string, defaultVal map[string]any) map[string]any

StringMapOr returns the value at key as a map[string]any, or def if not found.

func (*Synthra) StringOr

func (c *Synthra) StringOr(key, defaultVal string) string

StringOr returns the value at key as a string, or the default if not found.

func (*Synthra) StringSlice

func (c *Synthra) StringSlice(key string) ([]string, error)

StringSlice returns the value at key as a []string.

Example

ExampleSynthra_StringSlice demonstrates retrieving string slices.

package main

import (
	"context"
	"fmt"
	"log"

	"gopherly.dev/synthra"
	"gopherly.dev/synthra/codec"
)

func exampleStringSlice(cfg *synthra.Synthra, key string) []string {
	v, err := cfg.StringSlice(key)
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func main() {
	yamlContent := []byte(`
tags:
  - web
  - api
  - backend
`)

	cfg, err := synthra.New(
		synthra.WithContent(yamlContent, codec.YAML),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err = cfg.Load(context.Background()); err != nil {
		log.Fatal(err)
	}

	tags := exampleStringSlice(cfg, "tags")
	fmt.Printf("%v\n", tags)
}
Output:
[web api backend]

func (*Synthra) StringSliceOr

func (c *Synthra) StringSliceOr(key string, defaultVal []string) []string

StringSliceOr returns the value at key as a []string, or def if not found.

func (*Synthra) Time

func (c *Synthra) Time(key string) (time.Time, error)

Time returns the value at key as a time.Time.

func (*Synthra) TimeOr

func (c *Synthra) TimeOr(key string, defaultVal time.Time) time.Time

TimeOr returns the value at key as a time.Time, or the default if not found.

func (*Synthra) Values

func (c *Synthra) Values() *map[string]any

Values returns a pointer to a shallow copy of the loaded configuration map. If Load has not run yet, it returns a pointer to a new empty map.

type Validator

type Validator interface {
	Validate() error
}

Validator is an interface for structs that can validate their own configuration. The validation package uses the same contract (validation.Validator); a type implementing either satisfies both.

Directories

Path Synopsis
Package codec provides encoding and decoding functionality for configuration data.
Package codec provides encoding and decoding functionality for configuration data.
Package dumper provides configuration dumper implementations.
Package dumper provides configuration dumper implementations.
examples
basic command
Package main shows YAML file loading and struct binding with Synthra.
Package main shows YAML file loading and struct binding with Synthra.
casing command
Package main demonstrates Synthra's case-preserving, case-insensitive merge and shows how a JSON Schema acts as the canonical authority for key casing.
Package main demonstrates Synthra's case-preserving, case-insensitive merge and shows how a JSON Schema acts as the canonical authority for key casing.
codecs command
Package main shows codec usage end-to-end: JSON + TOML as input sources, merged YAML written out via WithFileDumperAs.
Package main shows codec usage end-to-end: JSON + TOML as input sources, merged YAML written out via WithFileDumperAs.
consul command
Package main loads local YAML, optionally merges Consul KV, then env.
Package main loads local YAML, optionally merges Consul KV, then env.
envsubst-layered command
Package main demonstrates three-layer resolver composition with Resolver.Or.
Package main demonstrates three-layer resolver composition with Resolver.Or.
hooks command
Package main demonstrates all three hook points in one pipeline.
Package main demonstrates all three hook points in one pipeline.
multi-schema command
Package main demonstrates two-phase validation using WithJSONSchemaFunc.
Package main demonstrates two-phase validation using WithJSONSchemaFunc.
schema command
Package main demonstrates JSON Schema defaults and WithEnvSubst.
Package main demonstrates JSON Schema defaults and WithEnvSubst.
testing command
Package main exists so `go run .` works; see README and *_test.go.
Package main exists so `go run .` works; see README and *_test.go.
webapp command
Package main demonstrates layered configuration (YAML defaults plus environment overrides), struct binding, and validation with Synthra.
Package main demonstrates layered configuration (YAML defaults plus environment overrides), struct binding, and validation with Synthra.
Package source provides configuration source implementations.
Package source provides configuration source implementations.
Package synthratest provides test helpers for packages that import gopherly.dev/synthra.
Package synthratest provides test helpers for packages that import gopherly.dev/synthra.

Jump to

Keyboard shortcuts

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