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
}
package appconfig
import (
"errors"
"fmt"
"net"
"time"
)
var validLevels = map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
func (c Config) Validate() error {
var problems []error
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
problems = append(problems, fmt.Errorf("addr %q is not host:port: %w", c.Addr, err))
}
if c.Workers < 1 {
problems = append(problems, fmt.Errorf("workers must be >= 1, got %d", c.Workers))
}
if c.Timeout < time.Millisecond || c.Timeout > 5*time.Minute {
problems = append(problems, fmt.Errorf("timeout %s out of range [1ms, 5m]", c.Timeout))
}
if !validLevels[c.LogLevel] {
problems = append(problems, fmt.Errorf("log-level %q unsupported", c.LogLevel))
}
return errors.Join(problems...)
}
package main
import (
"errors"
"flag"
"fmt"
"os"
"example.com/app/appconfig"
)
func main() {
cfg, err := appconfig.ParseConfig(os.Args[1:], os.Stderr)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
fmt.Fprintln(os.Stderr, "argument error:", err)
os.Exit(2)
}
if err := cfg.Validate(); err != nil {
fmt.Fprintln(os.Stderr, "invalid configuration:")
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("starting on %s with %d workers (timeout=%s, level=%s)\n",
cfg.Addr, cfg.Workers, cfg.Timeout, cfg.LogLevel)
// run(cfg) ...
}
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path))
anyhow::Context for adding error context without custom types
Share this code
Here's the card — post it anywhere.