Struct Binding

Automatically map configuration data to Go structs with type safety

Struct binding allows you to automatically map configuration data to your own Go structs. This provides type safety and a clean, idiomatic way to work with configuration.

Basic Struct Binding

Define a struct and bind it during configuration initialization:

type Config struct {
    Port int    `config:"port"`
    Host string `config:"host"`
}

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

if err := cfg.Load(context.Background()); err != nil {
    log.Fatalf("failed to load config: %v", err)
}

// c.Port and c.Host are now populated
log.Printf("Server: %s:%d", c.Host, c.Port)

Config Tags

Use the config tag to specify the configuration key for each field:

type Config struct {
    Port    int    `config:"port"`
    Host    string `config:"host"`
    Timeout int    `config:"timeout"`
}

The tag value should match the key name at that struct’s level in the configuration hierarchy.

Tag Naming

  • Tags are case-sensitive.
  • Use snake_case or lowercase for consistency.
  • Match the structure of your configuration files.
# config.yaml
port: 8080
host: localhost
timeout: 30

Default Values

Specify default values using the default tag:

type Config struct {
    Port    int           `config:"port" default:"8080"`
    Host    string        `config:"host" default:"localhost"`
    Debug   bool          `config:"debug" default:"false"`
    Timeout time.Duration `config:"timeout" default:"30s"`
}

Default values are used when:

  • The configuration key is not found.
  • The configuration file doesn’t exist.
  • Environment variables don’t provide the value.
var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),  // May not exist or be incomplete
    config.WithBinding(&c),
)

cfg.Load(context.Background())
// Fields use defaults if not present in config.yaml

Nested Structs

Create hierarchical configuration by nesting structs:

type Config struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"server"`
    
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port"`
        Username string `config:"username"`
        Password string `config:"password"`
    } `config:"database"`
}

Corresponding YAML:

server:
  host: localhost
  port: 8080

database:
  host: db.example.com
  port: 5432
  username: admin
  password: secret

Usage:

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

cfg.Load(context.Background())

log.Printf("Server: %s:%d", c.Server.Host, c.Server.Port)
log.Printf("Database: %s:%d", c.Database.Host, c.Database.Port)

Pointer Fields for Optional Values

Use pointer fields when values are truly optional:

type Config struct {
    Port     int     `config:"port"`
    Host     string  `config:"host"`
    CacheURL *string `config:"cache_url"`  // Optional
    Debug    *bool   `config:"debug"`       // Optional
}

If the configuration key is missing, pointer fields remain nil:

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

cfg.Load(context.Background())

if c.CacheURL != nil {
    log.Printf("Using cache: %s", *c.CacheURL)
} else {
    log.Printf("Cache disabled")
}

Deeply Nested Structures

For complex applications, create deeply nested configuration:

type AppConfig struct {
    Server struct {
        HTTP struct {
            Host string `config:"host"`
            Port int    `config:"port"`
        } `config:"http"`
        TLS struct {
            Enabled  bool   `config:"enabled"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    
    Database struct {
        Primary struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"primary"`
        Replica struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"replica"`
    } `config:"database"`
}

Corresponding YAML:

server:
  http:
    host: 0.0.0.0
    port: 8080
  tls:
    enabled: true
    cert_file: /etc/ssl/certs/server.crt
    key_file: /etc/ssl/private/server.key

database:
  primary:
    host: primary.db.example.com
    port: 5432
    database: myapp
  replica:
    host: replica.db.example.com
    port: 5432
    database: myapp

Slices and Maps

Bind slices and maps for collection data:

type Config struct {
    Hosts    []string          `config:"hosts"`
    Ports    []int             `config:"ports"`
    Metadata map[string]string `config:"metadata"`
    Features map[string]bool   `config:"features"`
}

YAML:

hosts:
  - localhost
  - example.com
  - api.example.com

ports:
  - 8080
  - 8081
  - 8082

metadata:
  version: "1.0.0"
  environment: production

features:
  auth: true
  cache: true
  debug: false

Type Conversion

The config package automatically converts between compatible types:

type Config struct {
    Port    int           `config:"port"`     // Converts from string "8080"
    Debug   bool          `config:"debug"`    // Converts from string "true"
    Timeout time.Duration `config:"timeout"`  // Converts from string "30s"
}

YAML (as strings):

port: "8080"      # String converted to int
debug: "true"     # String converted to bool
timeout: "30s"    # String converted to time.Duration

Common Issues and Solutions

Issue: Struct Not Populating

Problem: Fields remain at zero values after loading.

Solutions:

  1. Pass a pointer: Use WithBinding(&c), not WithBinding(c)
// Wrong
cfg := config.MustNew(config.WithBinding(c))

// Correct
cfg := config.MustNew(config.WithBinding(&c))
  1. Check tag names: Ensure config tags match your configuration structure
// If your YAML has "server_port", use:
Port int `config:"server_port"`

// Not:
Port int `config:"port"`
  1. Verify nested tags: All nested structs need the config tag
// Wrong - missing tag on Server struct
type Config struct {
    Server struct {
        Port int `config:"port"`
    }  // Missing `config:"server"`
}

// Correct
type Config struct {
    Server struct {
        Port int `config:"port"`
    } `config:"server"`
}

Issue: Type Mismatch Errors

Problem: Error during binding due to type incompatibility.

Solution: Ensure your struct types match the configuration data types or are compatible:

// If YAML has: port: 8080 (number)
Port int `config:"port"`  // Correct

// If YAML has: port: "8080" (string)
Port int `config:"port"`  // Still works - automatic conversion

Issue: Optional Fields Always Present

Problem: Want to distinguish between “not set” and “set to zero value”.

Solution: Use pointer types:

type Config struct {
    // Can't distinguish "not set" vs "set to 0"
    MaxConnections int `config:"max_connections"`
    
    // Can distinguish: nil = not set, &0 = set to 0
    MaxConnections *int `config:"max_connections"`
}

Complete Example

package main

import (
    "context"
    "log"
    "time"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host    string        `config:"host" default:"localhost"`
        Port    int           `config:"port" default:"8080"`
        Timeout time.Duration `config:"timeout" default:"30s"`
    } `config:"server"`
    
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port" default:"5432"`
        Username string `config:"username"`
        Password string `config:"password"`
        MaxConns *int   `config:"max_connections"` // Optional
    } `config:"database"`
    
    Features struct {
        EnableCache bool `config:"enable_cache" default:"true"`
        EnableAuth  bool `config:"enable_auth" default:"true"`
    } `config:"features"`
}

func (c *AppConfig) Validate() error {
    if c.Database.Host == "" {
        return errors.New("database host is required")
    }
    if c.Database.Username == "" {
        return errors.New("database username is required")
    }
    return nil
}

func main() {
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
        config.WithEnv("MYAPP_"),
        config.WithBinding(&appConfig),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    log.Printf("Server: %s:%d (timeout: %v)",
        appConfig.Server.Host,
        appConfig.Server.Port,
        appConfig.Server.Timeout)
    
    log.Printf("Database: %s:%d",
        appConfig.Database.Host,
        appConfig.Database.Port)
    
    if appConfig.Database.MaxConns != nil {
        log.Printf("Max DB connections: %d", *appConfig.Database.MaxConns)
    }
}

config.yaml:

server:
  host: 0.0.0.0
  port: 8080
  timeout: 60s

database:
  host: postgres.example.com
  port: 5432
  username: myapp
  password: secret123
  max_connections: 100

features:
  enable_cache: true
  enable_auth: true

Next Steps

For technical details, see the API Reference.