Building a Clean YAML Config Parser in Go (Without Viper)
So I was working on my project Snag, and like most projects at some point, I had to deal with configuration.
Initially, I thought about using Viper since it is widely adopted in the Go ecosystem. But after looking into it, it felt like overkill for what I needed. I wasn’t dealing with multiple config sources, env overrides, or complex layering. I just needed:
- A clean YAML config
- Strict parsing (no silent mistakes)
- Some flexibility in how users define commands
That last requirement turned out to be the most interesting part.
The Problem: Flexible Command Input
I wanted users to define commands in whichever way felt natural to them.
Some prefer writing commands as a single string:
run: go run main.go
Others prefer a more explicit format:
run:
- go
- run
- main.go
Both are valid. Both are useful.
Instead of forcing one format, I decided to support both.
Struct Design (Keeping It Simple)
The config structure itself is pretty straightforward:
// Root configuration structure
type Config struct {
Global GlobalConfig `yaml:"global"` // Global settings applied across all rules
Watch []Rule `yaml:"watch"` // List of watch rules
}
// Global-level configuration
type GlobalConfig struct {
Debounce string `yaml:"debounce"` // Delay before triggering actions (e.g., "500ms")
Ignore []string `yaml:"ignore"` // List of directories/files to ignore
}
// Individual watch rule
type Rule struct {
Name string `yaml:"name"` // Name of the rule (used for identification/logging)
Patterns []string `yaml:"patterns"` // File patterns to watch (e.g., **/*.go)
Run Command `yaml:"run"` // Command to execute (custom type)
Restart bool `yaml:"restart"` // Whether to restart process on change
Env map[string]string `yaml:"env"` // Environment variables for the command
}
Nothing fancy here. The interesting part is the Run field.
Why a Custom Type for Command?
If we used a plain []string, this would fail:
run: go run main.go
Because YAML cannot automatically convert a string into a slice.
So instead, I introduced a custom type:
// Command represents a shell command split into arguments
type Command []string
This allows us to define exactly how YAML values should be interpreted.
Custom Unmarshalling (The Core Idea)
func (c *Command) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
// Case 1: Command provided as a single string
case yaml.ScalarNode:
// Split the string into arguments like a shell would
args, err := shlex.Split(value.Value)
if err != nil {
return err
}
// Assign parsed arguments to Command
*c = args
// Case 2: Command provided as a list
case yaml.SequenceNode:
var arr []string
// Decode YAML sequence directly into []string
if err := value.Decode(&arr); err != nil {
return err
}
*c = arr
// Any other type is invalid
default:
return fmt.Errorf("invalid type for run: expected string or list")
}
return nil
}
This is where the flexibility comes from:
- If the YAML value is a string, we split it into arguments
- If it is already a list, we use it directly
The key detail here is using shlex.Split instead of strings.Split.
It correctly handles things like:
run: go run "main file.go"
Which becomes:
[]string{"go", "run", "main file.go"}
Defaults: Small Feature, Big Impact
A config system should not force users to define every single field.
func (c *Config) ApplyDefaults() {
// If debounce is not provided, use a sensible default
if c.Global.Debounce == "" {
c.Global.Debounce = "500ms"
}
}
This keeps things user-friendly while still giving control when needed.
Validation: Where Most Bugs Are Prevented
Parsing only tells you that the YAML is valid. It does not tell you if it is correct.
That is where validation comes in.
func (c *Config) Validate() error {
// Ensure at least one watch rule is defined
if len(c.Watch) == 0 {
return errors.New("no watch rules defined")
}
// Validate debounce duration format
if c.Global.Debounce != "" {
if _, err := time.ParseDuration(c.Global.Debounce); err != nil {
return fmt.Errorf("invalid debounce: %w", err)
}
}
// Validate each rule
for i, rule := range c.Watch {
// Rule must have a name
if strings.TrimSpace(rule.Name) == "" {
return fmt.Errorf("rule at index %d is missing a name", i)
}
// Rule must define at least one pattern
if len(rule.Patterns) == 0 {
return fmt.Errorf("rule '%s' must have at least one pattern", rule.Name)
}
// Rule must define a command to run
if len(rule.Run) == 0 {
return fmt.Errorf("rule '%s' is missing a run command", rule.Name)
}
}
return nil
}
This ensures:
- Required fields are present
- Values are valid (like duration parsing)
- Errors are caught early with clear messages
Strict YAML Parsing (Highly Recommended)
decoder.KnownFields(true) // Reject unknown fields instead of ignoring them
This enables strict mode.
If a user writes:
debouce: 500ms
Instead of silently ignoring it, the parser throws an error.
Loading Flow (Putting Everything Together)
func (cfgMgr *ConfigMgr) Load() error {
// Read config file from disk
data, err := os.ReadFile(cfgMgr.FilePath)
if err != nil {
return err
}
// Create YAML decoder
decoder := yaml.NewDecoder(bytes.NewReader(data))
// Enable strict field checking
decoder.KnownFields(true)
// Decode YAML into struct
if err := decoder.Decode(&cfgMgr.Config); err != nil {
return fmt.Errorf("invalid yaml format: %w", err)
}
// Apply default values for missing fields
cfgMgr.Config.ApplyDefaults()
// Validate the final configuration
if err := cfgMgr.Config.Validate(); err != nil {
return fmt.Errorf("configuration validation failed: %w", err)
}
return nil
}
The flow is intentionally simple:
- Read file
- Decode YAML
- Apply defaults
- Validate
Example Configuration
global:
debounce: 500ms
ignore:
- .git
- vendor
- node_modules
watch:
- name: server
patterns:
- "**/*.go"
restart: true
run: go run main.go
env:
PORT: "8080"
ENV: development
Debugging Tip: Print the Final Config
data, _ := json.MarshalIndent(cfgMgr.Config, "", " ")
fmt.Println(string(data))
This helps verify that parsing, defaults, and transformations are working correctly.
What This Approach Gets Right
After building this, a few things stood out:
- You do not always need a heavy library like Viper
- Custom unmarshalling gives you precise control
- Supporting flexible input formats improves usability
- Defaults and validation make the system robust
- Strict parsing prevents subtle configuration bugs
Final Thoughts
For tools like Snag, this approach hits a good balance between simplicity and control.
It avoids unnecessary abstraction while still being flexible and safe.
If your configuration needs are similar, single file, predictable structure, and a bit of flexibility, using go.yaml.in/yaml/v3 directly is a solid choice.