zoobzio January 23, 2026 Edit this page

Validation

fig supports custom validation through the Validator interface. This guide covers implementation patterns and best practices.

The Validator Interface

type Validator interface {
    Validate() error
}

If your config struct implements Validator, fig calls Validate() after all fields are populated. Validation errors are returned directly from Load.

Basic Validation

Range checks and simple constraints:

type ServerConfig struct {
    Port    int    `env:"PORT" default:"8080"`
    Timeout int    `env:"TIMEOUT" default:"30"`
    Host    string `env:"HOST" default:"0.0.0.0"`
}

func (c *ServerConfig) Validate() error {
    if c.Port <= 0 || c.Port > 65535 {
        return errors.New("port must be between 1 and 65535")
    }
    if c.Timeout <= 0 {
        return errors.New("timeout must be positive")
    }
    return nil
}

Cross-Field Validation

Validate relationships between fields:

type PoolConfig struct {
    MinConns int `env:"POOL_MIN" default:"5"`
    MaxConns int `env:"POOL_MAX" default:"25"`
}

func (c *PoolConfig) Validate() error {
    if c.MinConns > c.MaxConns {
        return fmt.Errorf("min connections (%d) cannot exceed max (%d)",
            c.MinConns, c.MaxConns)
    }
    if c.MinConns < 0 {
        return errors.New("min connections cannot be negative")
    }
    return nil
}

Multiple Errors

Collect all validation errors before returning:

import (
    "errors"
    "fmt"
    "strings"
)

type Config struct {
    Host     string `env:"HOST"`
    Port     int    `env:"PORT"`
    LogLevel string `env:"LOG_LEVEL"`
}

func (c *Config) Validate() error {
    var errs []string

    if c.Host == "" {
        errs = append(errs, "host is required")
    }
    if c.Port <= 0 || c.Port > 65535 {
        errs = append(errs, "port must be between 1 and 65535")
    }

    validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
    if c.LogLevel != "" && !validLevels[c.LogLevel] {
        errs = append(errs, fmt.Sprintf("invalid log level: %s", c.LogLevel))
    }

    if len(errs) > 0 {
        return errors.New(strings.Join(errs, "; "))
    }
    return nil
}

Validation vs Required

Use required:"true" for presence checks, Validate() for semantic checks:

type Config struct {
    // Use required for "must be set"
    APIKey string `env:"API_KEY" required:"true"`

    // Use validation for "must be valid format"
    Email string `env:"ADMIN_EMAIL"`
}

func (c *Config) Validate() error {
    if c.Email != "" && !strings.Contains(c.Email, "@") {
        return errors.New("admin email must be valid")
    }
    return nil
}

When to use required:

  • The field must have a non-zero value
  • Absence is a configuration error

When to use Validate:

  • The value must meet specific criteria
  • Cross-field dependencies exist
  • Format validation is needed

Conditional Validation

Validate based on other field values:

type Config struct {
    Mode     string `env:"MODE" default:"development"`
    TLSCert  string `env:"TLS_CERT"`
    TLSKey   string `env:"TLS_KEY"`
}

func (c *Config) Validate() error {
    if c.Mode == "production" {
        if c.TLSCert == "" || c.TLSKey == "" {
            return errors.New("TLS cert and key required in production mode")
        }
    }
    return nil
}

Nested Struct Validation

Each nested struct can implement its own Validator:

type DatabaseConfig struct {
    Host string `env:"DB_HOST" default:"localhost"`
    Port int    `env:"DB_PORT" default:"5432"`
}

func (c *DatabaseConfig) Validate() error {
    if c.Port <= 0 {
        return errors.New("database port must be positive")
    }
    return nil
}

type AppConfig struct {
    Database DatabaseConfig
    Name     string `env:"APP_NAME" required:"true"`
}

func (c *AppConfig) Validate() error {
    // Explicitly validate nested struct
    if err := c.Database.Validate(); err != nil {
        return fmt.Errorf("database config: %w", err)
    }

    if len(c.Name) > 64 {
        return errors.New("app name too long")
    }
    return nil
}

Note: fig only calls Validate() on the top-level struct. Nested validation must be called explicitly.

External Validation Libraries

Integrate with validation libraries:

import "github.com/go-playground/validator/v10"

var validate = validator.New()

type Config struct {
    Email string `env:"EMAIL" validate:"required,email"`
    URL   string `env:"URL" validate:"required,url"`
}

func (c *Config) Validate() error {
    return validate.Struct(c)
}

Testing Validation

Test validation logic independently:

func TestConfigValidation(t *testing.T) {
    tests := []struct {
        name    string
        config  Config
        wantErr bool
    }{
        {
            name:    "valid config",
            config:  Config{Port: 8080, Host: "localhost"},
            wantErr: false,
        },
        {
            name:    "invalid port",
            config:  Config{Port: -1, Host: "localhost"},
            wantErr: true,
        },
        {
            name:    "port out of range",
            config:  Config{Port: 70000, Host: "localhost"},
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.config.Validate()
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Error Handling

Validation errors are not wrapped in FieldError:

err := fig.Load(&cfg)
if err != nil {
    var fieldErr *fig.FieldError
    if errors.As(err, &fieldErr) {
        // This is a field-level error (required, type conversion)
        fmt.Printf("Field %s: %v\n", fieldErr.Field, fieldErr.Err)
    } else {
        // This is a validation error or other error
        fmt.Printf("Validation: %v\n", err)
    }
}

Next Steps