Skip to content

Config, CLI, and Protobuf

Runnable demos: examples/cli-config · examples/env-config

CLI config files (YAML, TOML, JSON)

Define a codec once and get type-safe parsing, structured validation errors, and auto-generated JSON Schema for free.

type Config struct {
    Port     int
    LogLevel string
}

var configCodec = codex.Struct[Config](
    codex.RequiredField("port",
        codex.Int().Refine(validate.RangeInt(1, 65535)),
        func(c Config) int { return c.Port },
        func(c *Config, v int) { c.Port = v },
    ),
    codex.DefaultField("log_level",
        codex.String().Refine(validate.OneOf("debug", "info", "warn", "error")),
        "info",  // default value — visible in generated schema
        func(c Config) string { return c.LogLevel },
        func(c *Config, v string) { c.LogLevel = v },
    ),
)

// In main() or cobra's PersistentPreRunE:
data, _ := os.ReadFile("config.toml")
cfg, err := format.TOML(configCodec).Unmarshal(data)
if err != nil {
    // err is codex.ValidationErrors — all field errors collected at once
    log.Fatal(err)
}

The same codec works with JSON, YAML, and TOML — swap format.TOML for format.YAML or format.JSON without touching the codec.

Environment variables (12-factor / containers)

format.FromEnv loads config from environment variables. The codec's schema drives string-to-type coercion — no per-field strconv code:

// Env var names: strings.ToUpper(prefix + field_name)
// "port"      + "APP_" → APP_PORT
// "log_level" + "APP_" → APP_LOG_LEVEL
cfg, err := format.FromEnv(configCodec, "APP_")
if err != nil {
    // err is codex.ValidationErrors
    log.Fatal(err)
}

Nested structs expand the prefix (db.hostAPP_DB_HOST). Slices use comma separation. Complex fields also accept JSON:

# Nested struct as JSON object
APP_DB='{"host":"localhost","port":5432,"name":"mydb"}'

# Slice as JSON array
APP_TAGS='["web","api","v2"]'

# StringMap as JSON object
APP_LABELS='{"env":"prod","team":"platform"}'

JSON takes precedence when the value starts with { or [.

Single env var (FromEnvVar)

format.FromEnvVar[T] loads a single typed value from one environment variable. The codec's schema drives coercion; all Refine constraints run after:

import "github.com/DaniDeer/go-codex/format"

// Load a typed port number — returns zero value when APP_PORT is not set
port, err := format.FromEnvVar("APP_PORT",
    codex.Int().Refine(validate.RangeInt(1, 65535)))
if err != nil {
    var envErr format.EnvVarError
    if errors.As(err, &envErr) {
        slog.Warn("env var invalid", "key", envErr.Key, "cause", envErr.Err)
        stats.ReportErrors(obs, "env", envErr.Err)
    }
    log.Fatal(err)
}

// Load a typed log level — falls back to default when not set
level, err := format.FromEnvVar("LOG_LEVEL",
    codex.String().Refine(validate.OneOf("debug", "info", "warn", "error")))
if level == "" {
    level = "info" // caller decides the default
}

FromEnvVar returns format.EnvVarError{Key, Err} when the variable is present but fails coercion or constraint validation. It returns the zero value of T (no error) when the variable is not set — the caller decides whether the variable is required.

Use FromEnvVar for ad-hoc overrides of individual settings. Use format.FromEnv when loading an entire config struct from environment variables.

Config file + env var overrides

// Decode the config file
data, _ := os.ReadFile("config.toml")
cfg, _ := format.TOML(configCodec).Unmarshal(data)

// Apply env var overrides
if port := os.Getenv("APP_PORT"); port != "" {
    portVal, _ := strconv.Atoi(port)
    cfg.Port = portVal
}

// Validate the merged config
if err := configCodec.Validate(cfg); err != nil {
    log.Fatal(err)
}

JSON Schema for editor autocomplete

import "github.com/DaniDeer/go-codex/render/openapi"

yamlBytes, _ := openapi.MarshalYAML(map[string]schema.Schema{
    "Config": configCodec.Schema,
})
// Paste into openapi.yaml or use as JSON Schema for VS Code settings.json

Scope of go-codex in CLI tools

Concern Owner
Config file parsing, validation, schema go-codex codec
CLI flags (--flag value), subcommands, --help cobra, pflag, or stdlib flag

go-codex handles config; cobra handles CLI UX. They compose cleanly.

Protobuf integration

go-codex and Protobuf solve different problems:

Concern Owner
Wire format, field numbers, binary encoding .proto + protoc-gen-go
Validation rules, richer documentation, JSON/YAML/TOML decode Codec[T]

Write a codec on top of the protoc-generated struct to add what proto cannot express:

// Generated by protoc-gen-go — do not edit.
type CreateUserRequest struct {
    Name  string
    Email string
    Age   int32
}

// Defined by you — the codec adds validation + documentation.
var CreateUserRequestCodec = codex.Struct[CreateUserRequest](
    codex.RequiredField("name",
        codex.String().Refine(validate.NonEmptyString).WithDescription("Display name."),
        func(r CreateUserRequest) string { return r.Name },
        func(r *CreateUserRequest, v string) { r.Name = v },
    ),
    // ...
)

What this gives you: - gRPC handles binary transport; the codec handles REST/JSON/YAML config validation. - render/openapi renders the codec's schema as OpenAPI documentation — no separate YAML file. - Validation rules (Refine) live in Go, next to the type, not scattered across proto options.

What this is not: go-codex does not generate .proto files from codecs, and does not read .proto files. The proto file is the wire-format source of truth; the codec is the validation-and-documentation source of truth.

See also