🧭The Go Journey - Structs, Pointers & Formatting

🧭The Go Journey - Structs, Pointers & Formatting
Photo by Marek Piwnicki / Unsplash

Introduction

When building a JSON parser in Go, understanding structs and pointers isn't just academic—it directly impacts your API design, performance, and user experience. This post explores nine key concepts through the lens of building a production-ready JSON parser with multiple formatting options.


Struct Methods & Receivers: Value vs Pointer

The decision between value and pointer receivers is one of the most important choices in Go API design.

Value Receivers: Immutability & Copies

Value receivers work with a copy of the struct, leaving the original unchanged:


func (s JSONString) ToUpper() JSONString {
    return JSONString(strings.ToUpper(string(s)))
  }

func (o JSONObject) Clone() JSONObject {
    clone := make(JSONObject)
    for k, v := range o.Data {
        clone.Data[k] = v
    }
    return clone
}

How this works:

The value receiver (s JSONString) means Go creates a copy of the string when the method is called. The method converts the JSONString to a regular string, applies strings.ToUpper(), then converts back to JSONString type. The original value remains unchanged—you get a new uppercase version instead. Similarly, Clone() creates a completely independent copy of the JSONObject by making a new map and copying all key-value pairs, ensuring modifications to the clone don't affect the original.

Usage for value receviers:

- The struct is small (a few primitive fields)
- You want immutable operations
- You're implementing a functional-style API

Pointer Receivers: In-Place Modification

Now let's add usage of pointer and modify a original struct:


func (s *JSONString) ToUpperInPlace() {
  *s = JSONString(strings.ToUpper(string(*s)))
}

func (o *JSONObject) Reset() {
    o.Data = make(map[string]JSONValue)
}

Use pointer receivers when:
- The struct is large
- You need to modify the struct
- You want to avoid copying overhead

Key insight: Once you use a pointer receiver for one method, use it for *all* methods on that type for consistency.


Struct Embedding & Composition
Go doesn't have inheritance, but struct embedding provides powerful composition patterns.


type AnnotatedJSON struct {
    parser.JSONObject      // Embedded - gets all methods automatically
    Source   string       
    ParsedAt time.Time     
}


obj := AnnotatedJSON{
    JSONObject: parser.JSONObject{Data: make(map[string]JSONValue)},
    Source:     "api.example.com",
    ParsedAt:   time.Now(),
}


obj.Data["key"] = parser.JSONString("value") 
obj.Source = "updated"   

Did you notice that parser.JSONObject has no field name - this makes it "embedded". Source and ParsedAt have field names - these are regular fields.

Maybe you will ask, what happened when you embed?

All fields from JSONObject become accessible directly:


type JSONObject struct {
    Data map[string]JSONValue
}

// Two way for access 
obj.JSONObject.Data["key"] = value  // Explicit path
obj.Data["key"] = value             // Direct access (promoted)

Now because we embed the JSONObject in AnnotatedJSON, all methods from JSONObject become available on AnnotatedJSON:


func (j *JSONObject) Get(key string) JSONValue { ... }
func (j *JSONObject) Set(key string, val JSONValue) { ... }

// You can call them directly on AnnotatedJSON:
obj.Get("key")           // Works! Calls JSONObject.Get()
obj.Set("key", value)    // Works! Calls JSONObject.Set()

btw those two first methods are implemented in JSONObject directly because we use a pointer there with * before name of Struct name.

Initialization Syntax:


obj := AnnotatedJSON{
    JSONObject: parser.JSONObject{           
        Data: make(map[string]JSONValue), 
    },
    Source:     "api.example.com",           
    ParsedAt:   time.Now(),                  
}

In example above we initiate embeded struct. Why JSONObject is needed:

  • Even though it's embedded, you still need to specify which embedded struct you're initializing
  • This is especially important if you embed multiple structs

The power: AnnotatedJSON acts like it has all the methods and fields of JSONObject, plus its own additional fields. It's like extending a class, but through composition instead of inheritance.


Struct Tags for Configuration
Struct tags provide metadata that can control serialization, validation, and formatting:


type FormatOptions struct {
    Indent      string `json:"indent" format:"2-spaces"`
    ColorOutput bool   `json:"color" format:"enabled"`
    ShowTypes   bool   `json:"show_types" format:"verbose"`
}

Pointer Semantics Deep Dive
Understanding when copies happen versus when references are shared is critical:


type JSONDiff struct {
    LeftPath  *JSONValue   
    RightPath *JSONValue   
    Changes   []Change     // Slice - reference type (already a pointer internally)
}

So every reference type like (map,slice, channel) already contain pointers internally. (To topic of channel we will get later). Just one thing everyone have had in mind that with pointers copying it shares the same data like original.


Constructor Patterns with Structs
Go offers multiple ways to create and initialize structs:


func NewFormatter() *Formatter {
    return &Formatter{
        Indent:    "  ",
        ShowTypes: false,
        Compact:   false,
    }
}

func NewFormatterWithIndent(indent string) *Formatter {
    return &Formatter{
        Indent:    indent,
        ShowTypes: false,
        Compact:   false,
    }
}


formatter := &Formatter{
    Indent:    "  ",
    ShowTypes: true,
}


var formatter Formatter  // Valid! Uses zero values

In Go, every type has a zero value - the default value when a variable is declared but not initialized.


Struct Comparison & Equality
Structs can be compared with == if all fields are comparable, but deep equality often requires custom logic:


func (j *JSONObject) Equals(other *JSONObject) bool {
    if len(j.Data) != len(other.Data) {
        return false
    }
    for k, v := range j.Data {
        otherV, exists := other.Data[k]
        if !exists || !valuesEqual(v, otherV) {
            return false
        }
    }
    return true
}


Method Chaining with Pointers
Fluent APIs make configuration code more readable:


func (f *Formatter) WithIndent(indent string) *Formatter {
    f.Indent = indent
    return f  // Return pointer for chaining
}

func (f *Formatter) WithTypeAnnotations(show bool) *Formatter {
    f.ShowTypes = show
    return f
}

func (f *Formatter) WithColors(enabled bool) *Formatter {
    f.ColorOutput = enabled
    return f
}

formatter := NewFormatter().
    WithIndent("    ").
    WithTypeAnnotations(true).
    WithColors(true)

Hint: pointers receivers are essential for chaining—you need to return the same instance.


Putting It All Together
Here's how these concepts combine in a real JSON parser CLI:


type Parser struct {
    *Formatter           
    Source    string
    ParsedAt  time.Time
}

func NewParser(filename string) *Parser {
    return &Parser{
        Formatter: NewFormatter().
            WithIndent("  ").
            WithTypeAnnotations(false),
        Source:   filename,
        ParsedAt: time.Now(),
    }
}

func (p Parser) Clone() Parser {
    return Parser{
        Formatter: p.Formatter,
        Source:    p.Source,
        ParsedAt:  time.Now(),
    }
}

func (p *Parser) SetFormatter(f *Formatter) *Parser {
    p.Formatter = f
    return p
}

Conclusion
Mastering structs and pointers in Go is about understanding:
1. When to copy vs share: Value receivers copy, pointer receivers share
2. Composition over inheritance: Embedding gives you flexibility
3. API design: Method receivers shape your API's ergonomics
4. Performance: Memory layout and pointer usage impact efficiency
5. Patterns: Constructors, chaining, and zero values make APIs usable


The JSON parser example demonstrates how these concepts work together to create clean, efficient, idiomatic Go code. Whether you're building parsers, formatters, or any other Go application, these fundamentals will guide your design decisions.


Further Reading
- Effective Go - Methods
- Go FAQ - Should I define methods on values or pointers?
- The Go Memory Model