go 115 lines · 3 tabs

Parsing and Validating CLI Flags into a Typed Config Struct in Go

Shared by codesnips Jul 2026
3 tabs
package appconfig

import (
	"flag"
	"io"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Addr     string
	Workers  int
	Timeout  time.Duration
	LogLevel string
	Verbose  bool
}

func ParseConfig(args []string, out io.Writer) (Config, error) {
	fs := flag.NewFlagSet("server", flag.ContinueOnError)
	fs.SetOutput(out)

	var cfg Config
	fs.StringVar(&cfg.Addr, "addr", envOr("APP_ADDR", ":8080"), "listen address host:port")
	fs.IntVar(&cfg.Workers, "workers", envOrInt("APP_WORKERS", 4), "number of worker goroutines")
	fs.DurationVar(&cfg.Timeout, "timeout", 30*time.Second, "request timeout, e.g. 1500ms or 5s")
	fs.StringVar(&cfg.LogLevel, "log-level", envOr("APP_LOG_LEVEL", "info"), "debug|info|warn|error")
	fs.BoolVar(&cfg.Verbose, "verbose", false, "enable verbose output")

	if err := fs.Parse(args); err != nil {
		return Config{}, err
	}
	return cfg, nil
}

func envOr(key, fallback string) string {
	if v, ok := os.LookupEnv(key); ok && v != "" {
		return v
	}
	return fallback
}

func envOrInt(key string, fallback int) int {
	if v, ok := os.LookupEnv(key); ok {
		if n, err := strconv.Atoi(v); err == nil {
			return n
		}
	}
	return fallback
}
3 files · go Explain with highlit

This snippet shows a small, self-contained pattern for turning raw command-line arguments into a validated, typed configuration value in Go using only the standard library. The design keeps parsing (a mechanical concern) separate from validation (a policy concern), which makes the config easy to test and reason about.

In config.go, the Config struct is the single typed representation the rest of the program depends on. Rather than pass a *flag.FlagSet around, the code parses flags into local variables and then hydrates a plain struct. ParseConfig accepts an explicit args []string slice and an io.Writer for usage output instead of reaching for the package-level flag.CommandLine; this is what makes the function unit-testable, because a test can feed it any argument slice and capture output. flag.ContinueOnError is chosen over the default ExitOnError so a parse failure returns an error instead of calling os.Exit, letting the caller decide how to react.

The flag definitions bind directly to typed variables via StringVar, IntVar, BoolVar, and DurationVar. Using DurationVar means an input like --timeout=1500ms is parsed by the stdlib into a real time.Duration, so the rest of the program never touches strings. Environment fallbacks are folded in through envOr, giving flags precedence over env vars over defaults — a common twelve-factor ordering.

Validation lives in the Validate method in validate.go. It accumulates problems into a slice and joins them with errors.Join, so a single run reports every issue at once instead of failing on the first. This surfaces things like a non-positive Workers count, an unroutable Addr, an unsupported LogLevel, or a Timeout outside sane bounds. Returning a joined error keeps the call site simple while still preserving each underlying error for errors.Is checks.

Finally, main.go wires it together: it calls ParseConfig(os.Args[1:], os.Stderr), distinguishes flag.ErrHelp (a clean exit) from real parse errors, and only then runs Validate. This ordering matters — malformed syntax and invalid values are different failure modes and deserve different exit codes. The trade-off of this approach is a little boilerplate per field, but it yields a config that is explicit, validated once at startup, and impossible to misread later.


Related snips

Share this code

Here's the card — post it anywhere.

Parsing and Validating CLI Flags into a Typed Config Struct in Go — share card
Link copied