The Go Journey - Building a JSON Parser in Go

The Go Journey - Building a JSON Parser in Go
Photo by Ryan Klaus / Unsplash

Learning a new programming language can be overwhelming. You read through tutorials, watch videos, but nothing quite clicks until you build something real. That's why I created a JSON parser CLI in Go—to learn by doing.

In this post, I'll walk you through the key concepts I learned while building this project, from basic types to advanced patterns. Whether you're new to Go or just looking for a practical learning project, this guide will help you understand how everything fits together.


Why a JSON Parser?

JSON parsing is perfect for learning because it covers:

- Data structures - objects, arrays, primitives

- Recursion - nested structures require recursive processing

- Error handling - invalid JSON needs clear error messages

- Interfaces - different JSON types share common behaviors

- Real-world utility - you'll actually use this tool!


The project overview

The final tool is a CLI application with four commands:


./json-parser parse data.json      # Pretty-print JSON
./json-parser validate config.json # Check syntax
./json-parser format data.json     # Format with indentation
./json-parser stats data.json      # Show statistics

Let's break down how we built it.


Part 1: Understanding Go's Type System
Defining JSON Value Types

The first challenge: JSON has different value types (objects, arrays, strings, numbers, booleans, null). How do we represent these in Go?


The Solution: Interfaces


type JSONValue interface { Type() string }


This interface means "anything that can return its type is a JSONValue."

Now we can create types that implement it:


type JSONString struct {    Value string}
func (s *JSONString) Type() string {    return "string"}


Why this works: Go's interfaces are implicit. You don't declare "I implement this interface"—you just implement the methods, and Go figures it out.

Creating All JSON Types


type JSONObject struct {
    Data map[string]JSONValue  // Key-value pairs
}

type JSONArray struct {
    Elements []JSONValue  // Ordered list
}

type JSONNumber struct {
    Value float64  // JSON numbers are floats
}

type JSONBoolean struct {
    Value bool
}

type JSONNull struct{}  // Empty struct for null

Key Learning: Structs are Go's way of creating custom types. Each field has a name and type


Part 2: Building the Parser

The Parser Interface


type Parser interface {
    Parse(reader io.Reader) (*ParseResult, error)
    ParseFile(filepath string) (*ParseResult, error)
}

Why an interface? It lets us swap implementations. Want a different parsing strategy? Just create a new type that implements these methods.

Implementing the Parser

type StandardParser struct{}

func (p *StandardParser) Parse(reader io.Reader) (*ParseResult, error) {
    var raw interface{}
    decoder := json.NewDecoder(reader)

    if err := decoder.Decode(&raw); err != nil {
        return nil, fmt.Errorf("failed to decode JSON: %w", err)
    }

    value, err := convertToJSONValue(raw)
    return &ParseResult{Value: value, Error: err}, err
}

What's happening here:

1. `io.Reader` is Go's abstraction for "anything you can read from"

2. We decode JSON into Go's `interface{}` (any type)

3. We convert it to our custom types

4. We return results with proper error handling

The Magic: Type Switches
Converting Go's generic types to our custom types requires checking what type we have:


func convertToJSONValue(raw interface{}) (JSONValue, error) {
    switch typedValue := raw.(type) {
    case map[string]interface{}:
        jsonObject := &JSONObject{Data: make(map[string]JSONValue)}
        for key, value := range typedValue {
            converted, err := convertToJSONValue(value)  
            if err != nil {
                return nil, err
            }
            jsonObject.Data[key] = converted
        }
        return jsonObject, nil

    case []interface{}:
        jsonArray := &JSONArray{Elements: make([]JSONValue, 0, len(typedValue))}
        for _, value := range typedValue {
            converted, err := convertToJSONValue(value) 
            if err != nil {
                return nil, err
            }
            jsonArray.Elements = append(jsonArray.Elements, converted)
        }
        return jsonArray, nil

    case string:
        return &JSONString{Value: typedValue}, nil

    case float64:
        return &JSONNumber{Value: typedValue}, nil

    case bool:
        return &JSONBoolean{Value: typedValue}, nil

    default:
        return nil, fmt.Errorf("unsupported type: %T", typedValue)
    }
}

The Beauty of Recursion: When we encounter a nested object or array, we just call `convertToJSONValue` again. It handles any level of nesting!


Part 3: Error Handling Done Right
Go doesn't have exceptions. Instead, functions return errors, and you handle them explicitly.


type ParseError struct {
    Line    int
    Column  int
    Message string
    Err     error  // Wrapped error
}

func (e *ParseError) Error() string {
    if e.Line > 0 {
        return fmt.Sprintf("parse error at line %d, column %d: %s",
            e.Line, e.Column, e.Message)
    }
    return fmt.Sprintf("parse error: %s", e.Message)
}

func (e *ParseError) Unwrap() error {
    return e.Err
}

Why custom errors? They carry more context. Instead of "syntax error," you get "parse error at line 5, column 12: unexpected comma."

Error Wrapping


if err := decoder.Decode(&raw); err != nil {
    return nil, fmt.Errorf("failed to decode JSON: %w", err)
}

The `%w` verb wraps the error. You can later unwrap it with `errors.Unwrap()` to check the underlying cause.


Part 4: Formatting Output
The Formatter


type Formatter struct {
    Indent string
}

func (f *Formatter) Format(value JSONValue, writer io.Writer) error {
    return f.formatWithIndent(value, writer, 0)
}

func (f *Formatter) formatWithIndent(value JSONValue, writer io.Writer, depth int) error {
    indent := strings.Repeat(f.Indent, depth)
    nextIndent := strings.Repeat(f.Indent, depth+1)

    switch typedValue := value.(type) {
    case *JSONObject:
        fmt.Fprint(writer, "{\n")
        index := 0
        for key, childValue := range typedValue.Data {
            fmt.Fprintf(writer, "%s\"%s\": ", nextIndent, key)
            f.formatWithIndent(childValue, writer, depth+1)  // Recursion!
            if index < len(typedValue.Data)-1 {
                fmt.Fprint(writer, ",")
            }
            fmt.Fprint(writer, "\n")
            index++
        }
        fmt.Fprintf(writer, "%s}", indent)
    // ... handle other types
    }
    return nil
}

Recursion Again: Formatting nested structures? Just call `formatWithIndent` with increased depth. The indentation automatically adjusts!


Part 5: Validation
The Validator Interface


type Validator interface {
    Validate(reader io.Reader) error
    ValidateFile(filepath string) error
}

Implementation


type SyntaxValidator struct{}

func (v *SyntaxValidator) Validate(reader io.Reader) error {
    decoder := json.NewDecoder(reader)

    var raw interface{}
    if err := decoder.Decode(&raw); err != nil {
        return NewParseError("invalid JSON syntax", err)
    }

    if decoder.More() {
        return NewParseError("unexpected data after JSON value", nil)
    }

    return nil
}

Simple but Effective: We just try to decode the JSON. If it fails, we return a descriptive error.


Part 6: The CLI
Using Go's flag Package

func main() {
    parseCmd := flag.NewFlagSet("parse", flag.ExitOnError)
    validateCmd := flag.NewFlagSet("validate", flag.ExitOnError)

    if len(os.Args) < 2 {
        printUsage()
        os.Exit(1)
    }

    switch os.Args[1] {
    case "parse":
        parseCmd.Parse(os.Args[2:])
        handleParse(parseCmd.Args())
    case "validate":
        validateCmd.Parse(os.Args[2:])
        handleValidate(validateCmd.Args())
    // ... other commands
    }
}

Commands handlers:


func handleParse(args []string) {
    if len(args) < 1 {
        fmt.Println("Error: file path required")
        os.Exit(1)
    }

    if err := cmd.ParseCommand(args[0]); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Part 7: Testing
Go makes testing a first-class citizen with built-in test support.

func TestParseCommand(t *testing.T) {
    tests := []struct {
        name        string
        filepath    string
        shouldError bool
    }{
        {
            name:        "valid simple JSON",
            filepath:    "../testdata/simple.json",
            shouldError: false,
        },
        {
            name:        "invalid JSON",
            filepath:    "../testdata/invalid.json",
            shouldError: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ParseCommand(tt.filepath)
            if tt.shouldError && err == nil {
                t.Errorf("expected error but got none")
            }
            if !tt.shouldError && err != nil {
                t.Errorf("unexpected error: %v", err)
            }
        })
    }
}

Table-driven tests are Go's pattern for testing multiple scenarios. Define your test cases in a slice, then loop through them.


Key Takeaways


1. Interfaces are Powerful. They let you write flexible code. `JSONValue`, `Parser`, `Validator`—all interfaces that enable polymorphism.
2. Explicit Error Handling. No hidden exceptions. Every error is visible and handled deliberately.
3. Recursion Solves Nesting. Whether parsing or formatting, recursion elegantly handles arbitrary nesting depth.
4. Type Switches for Flexibility. `switch typedValue := value.(type)` lets you handle different types cleanly.
5. Go's Standard Library is Rich. `io.Reader`, `encoding/json`, `flag`—Go's stdlib covers most needs.
6. Testing is Built-in. No external frameworks needed. `go test` just works.


What I Learned
Building this parser taught me more than any tutorial could:- How Go's type system actually works- Why interfaces matter (and when not to use them)- How to structure a real Go project- The importance of explicit error handling- How recursion simplifies complex problems


Try It Yourself
The complete code is available on GitHub. Clone it, build it, and experiment:

git clone https://github.com/wrola/json-parser 
cd json-parser
go build./json-parser parse testdata/nested.json


Ideas to Extend It
1. Add JSON Path support - Extract values using paths like `user.profile.name`

2. Pretty-print with colours - Use ANSI codes to colourize output

3. Add JSON Schema validation - Validate against a schema

4. Support YAML output - Convert JSON to YAML format

5. Add performance benchmarks - Compare parsing speed on large files


Conclusion
This JSON parser taught me Go's fundamentals through practice. I struggled with interfaces, wrestled with error handling, and debugged recursive functions. But in the end, I had both working code and deep understanding.
If you're learning Go, pick a project like this. Something practical, moderately complex, and genuinely useful. You'll learn more in a weekend than weeks of reading documentation. The repository can be found here.
Happy coding! 🚀