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

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:

  1. Read file
  2. Decode YAML
  3. Apply defaults
  4. 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.