š§The Go Journey - Structs, Pointers & Formatting
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 valuesIn 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