Go Advanced Patterns: Struct Tags, Error Handling, and Type Safety

Jay Kye
12 min read

Deep dive into Go's powerful features including struct tags for system mapping, error wrapping patterns, pointer safety, SQL parameterization, and compile-time interface verification.

GoGolangBackendBest PracticesError HandlingType SafetySQLStruct Tags

Go provides powerful features that enable safe, maintainable, and efficient code. In this comprehensive guide, we'll explore essential Go patterns that every backend developer should master: struct tags, error wrapping, pointer safety, SQL parameterization, and compile-time interface verification.

Struct Tags: Bridging Different Systems

Understanding Struct Tags

Struct tags are a core Go feature that provides metadata to help various libraries properly handle structs. They enable seamless mapping between different systems like databases, JSON APIs, and validation frameworks.

1. Mapping Between Different Systems

Database Query Example

// Querying from database
func (r *TodoRepository) GetByID(ctx context.Context, id int) (*Todo, error) {
    query := `SELECT id, user_id, title FROM todos WHERE id = $1`
    var todo Todo
    
    // Using db tags to map database columns
    err := r.db.QueryRow(ctx, query, id).Scan(
        &todo.ID,     // Maps to db:"id" column
        &todo.UserID, // Maps to db:"user_id" column  
        &todo.Title,  // Maps to db:"title" column
    )
    return &todo, err
}

JSON Response Example

// Returning as JSON response
func GetTodoHandler(w http.ResponseWriter, r *http.Request) {
    todo := &Todo{ID: 1, UserID: 123, Title: "Learn Go"}
    
    // Using json tags to determine JSON field names
    json.Marshal(todo)
    // Result: {"id": 1, "user_id": 123, "title": "Learn Go"}
}

2. What Happens Without Tags?

// Without tags
type Todo struct {
    ID     int    // Starts with uppercase
    UserID int    
    Title  string 
}

// JSON result: {"ID": 1, "UserID": 123, "Title": "Learn Go"}
// Problem: Awkward capitalized JSON field names

3. Advanced Tag Usage

type Todo struct {
    ID          int        `db:"id" json:"id"`
    UserID      int        `db:"user_id" json:"user_id"`
    Title       string     `db:"title" json:"title" validate:"required,max=500"`
    Description *string    `db:"description" json:"description,omitempty"`
    Password    string     `db:"password" json:"-"`  // Excluded from JSON
}

Tag Options:

  • json:"description,omitempty": Exclude from JSON if nil
  • json:"-": Completely exclude from JSON serialization
  • validate:"required,max=500": For validation libraries

When to Use Which Tags

Use CaseTagExample
Database ORM/Driverdb:db:"user_id"
REST APIjson:json:"user_id"
Form Processingform:form:"username"
Validationvalidate:validate:"required"
GraphQLgraphql:graphql:"userId"

Are Struct Tags Standard?

// Standard library defined tags
`json:"name"`           // encoding/json package
`xml:"name"`            // encoding/xml package  
`yaml:"name"`           // gopkg.in/yaml.v3
`form:"name"`           // net/url, multipart/form-data

// Library-defined tags
`db:"column_name"`      // database/sql, GORM, etc.
`validate:"required"`   // go-playground/validator
`gorm:"primaryKey"`     // GORM ORM
`graphql:"field_name"`  // GraphQL libraries
`protobuf:"1"`         // Protocol Buffers

Key Takeaway: Some tags are standard (from Go's standard library), but most are conventions defined by specific libraries.

Understanding omitempty: Is It Like TypeScript's ??

1. Pointer + omitempty = Optional (Similar to TS ?)

type User struct {
    Email *string `json:"email,omitempty"`  // Completely excluded if nil
}

// Usage
user1 := User{Email: nil}           // JSON: {}
email := "test@example.com"
user2 := User{Email: &email}        // JSON: {"email": "test@example.com"}

2. Plain omitempty = Zero Value Exclusion

type User struct {
    Age int `json:"age,omitempty"`  // Excluded if 0
}

// Usage  
user1 := User{Age: 0}   // JSON: {}
user2 := User{Age: 25}  // JSON: {"age": 25}

Real-World Example: Todo Model

type Todo struct {
    ID          int        `json:"id"`                        // Always included
    Title       string     `json:"title"`                     // Always included
    Description *string    `json:"description,omitempty"`     // Excluded if nil (Optional)
    Completed   bool       `json:"completed"`                 // Included even if false
}

type UpdateTodoInput struct {
    Title       *string `json:"title,omitempty"`       // Optional update
    Description *string `json:"description,omitempty"` // Optional update
    Completed   *bool   `json:"completed,omitempty"`   // Optional update
}

Summary:

  • Pointer + omitempty: "This field is optional, exclude from JSON if absent"
  • Plain omitempty: "Exclude from JSON if it has the default value"

Error Wrapping: %w vs %v

The difference between %w and %v relates to Go 1.13's error wrapping feature, which enables error chain maintenance.

1. %w (Error Wrapping)

originalErr := errors.New("database connection failed")
wrappedErr := fmt.Errorf("failed to create todo: %w", originalErr)

// Later, extract the original error
if errors.Is(wrappedErr, originalErr) {
    fmt.Println("Found the original error!")
}

// Unwrap the error chain to get original error
unwrapped := errors.Unwrap(wrappedErr)
fmt.Println(unwrapped) // "database connection failed"

2. %v (String Conversion)

originalErr := errors.New("database connection failed")
stringErr := fmt.Errorf("failed to create todo: %v", originalErr)

// Connection to original error is lost
if errors.Is(stringErr, originalErr) {
    fmt.Println("This code won't execute")
}

// Unwrap is impossible
unwrapped := errors.Unwrap(stringErr) // Returns nil

Real-World Usage Examples

Using %w (Maintaining Error Chain)

func (r *TodoRepository) Create(ctx context.Context, userID int, input CreateTodoInput) (*Todo, error) {
    // Wrap database error
    err := r.db.QueryRow(ctx, query, userID, input.Title).Scan(...)
    if err != nil {
        return nil, fmt.Errorf("failed to create todo: %w", err)
        // Preserves original PostgreSQL error information
    }
}

// At the call site
todo, err := repo.Create(ctx, userID, input)
if err != nil {
    // Can check original error type
    if errors.Is(err, sql.ErrNoRows) {
        // Handle specific PostgreSQL error
    }
    
    // Or unwrap to specific error type
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        fmt.Printf("PostgreSQL error code: %s", pgErr.Code)
    }
}

Using %v (String Conversion Only)

func logError(operation string, err error) {
    // Only for logging, so %v is fine
    log.Printf("Operation %s failed: %v", operation, err)
}

When to Use Which?

Use %w when: 🔗

  • Caller needs to check original error type
  • Error chain must be maintained
  • Returning errors from libraries or packages
  • Database, Network, File I/O errors

Use %v when: 📝

  • Simple logging or debugging
  • Creating end-user messages
  • Error type checking not needed
  • Only string conversion required
// Repository Layer - Use %w (maintain error chain)
return nil, fmt.Errorf("failed to create todo: %w", err)

// Service Layer - Use %w (business logic errors)
return nil, fmt.Errorf("validation failed: %w", err)

// Logging - Use %v (string conversion)
log.Printf("User %d failed to create todo: %v", userID, err)

Key Point: %w "wraps" errors so you can find the original later, while %v simply converts to a string!

Pointer Double-Check Pattern

Why Check Twice?

// Add search filter 
if filter.Search != nil && *filter.Search != "" {
    query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIndex, argIndex)
    searchTerm := "%" + *filter.Search + "%"
    args = append(args, searchTerm)
    argIndex++
}

1. Preventing Nil Pointer Dereference

if filter.Search != nil && *filter.Search != "" {
//  ↑ First check    ↑ Second check

2. Purpose of Each Check

// When filter.Search is nil
filter := TodoFilter{Search: nil}

// Without first check, direct dereference causes panic
if *filter.Search != "" {  // ❌ PANIC! nil pointer dereference
    // ...
}

// Correct approach
if filter.Search != nil && *filter.Search != "" {
    // ✅ Executes safely
}

3. Behavior by Scenario

// Scenario 1: Search is nil
filter := TodoFilter{Search: nil}
if filter.Search != nil && *filter.Search != "" {
    // Won't execute (first condition is false)
}

// Scenario 2: Search is pointer to empty string  
emptyString := ""
filter := TodoFilter{Search: &emptyString}
if filter.Search != nil && *filter.Search != "" {
    // Won't execute (second condition is false)
}

// Scenario 3: Search is pointer with actual value
searchTerm := "golang"
filter := TodoFilter{Search: &searchTerm}
if filter.Search != nil && *filter.Search != "" {
    // ✅ Executes (both conditions true)
}

Go's Short-Circuit Evaluation

Go evaluates boolean expressions left-to-right and stops as soon as the result is determined. This prevents nil pointer panics in our double-check pattern.

Why Use Pointers in Filter Structs?

type TodoFilter struct {
    Search *string `json:"search,omitempty"`  // Optional field
}

Reasons for using pointers:

  1. Explicit Optional: nil = "no search", "" = "search for empty string"
  2. JSON Serialization: omitempty excludes field from JSON if nil
  3. API Design: If client doesn't send search filter, it remains nil

Conclusion: filter.Search != nil && *filter.Search != "" is Go's idiomatic pattern for safely checking "if search term is provided and not empty"!

SQL Parameterization: Args and ArgIndex

1. Understanding Parameterized Queries

Parameterized queries prevent SQL injection by separating SQL code from data values.

2. Dynamic Query Building Process

// Starting state
query := "SELECT ... FROM todos WHERE user_id = $1"
args := []interface{}{userID}  // [123]
argIndex := 2  // Next parameter will be $2

// Adding first condition (Completed filter)
if filter.Completed != nil {
    query += " AND completed = $2"        // Use $2
    args = append(args, *filter.Completed) // [123, true]
    argIndex++  // Increment to 3
}

// Adding second condition (Search filter)  
if filter.Search != nil && *filter.Search != "" {
    query += " AND (title ILIKE $3 OR description ILIKE $3)"  // Use $3
    searchTerm := "%golang%"
    args = append(args, searchTerm)  // [123, true, "%golang%"]
    argIndex++  // Increment to 4
}

3. Final Result

// Final query
query = `
    SELECT id, user_id, title, description, completed, created_at, updated_at
    FROM todos 
    WHERE user_id = $1 
    AND completed = $2 
    AND (title ILIKE $3 OR description ILIKE $3)
`

// Final args
args = []interface{}{123, true, "%golang%"}

// Actual execution
rows, err := r.db.Query(ctx, query, args...)

Why ArgIndex Is Necessary

Each condition is optional, so parameter numbers vary:

// Scenario 1: Only completed filter
// query: "WHERE user_id = $1 AND completed = $2"
// args: [123, true]

// Scenario 2: Only search filter  
// query: "WHERE user_id = $1 AND (title ILIKE $2 OR description ILIKE $2)"
// args: [123, "%golang%"]

// Scenario 3: Both filters
// query: "WHERE user_id = $1 AND completed = $2 AND (title ILIKE $3 OR description ILIKE $3)"
// args: [123, true, "%golang%"]

SQL Injection Prevention

❌ Dangerous code:

// User input: "; DROP TABLE todos; --"
query := "WHERE title ILIKE '%" + userInput + "%'"
// Result: "WHERE title ILIKE '%'; DROP TABLE todos; --%'"
// 😱 Database gets deleted!

✅ Safe code:

query := "WHERE title ILIKE $1"
args := []interface{}{userInput}
// PostgreSQL safely escapes the input

Key Point: argIndex is a counter that tracks the correct parameter number ($1, $2, $3...) for dynamically added conditions, while args is an array storing the actual values in order!

Compile-Time Interface Implementation Verification

Go's idiomatic pattern for verifying interface implementation at compile time.

Purpose and How It Works

1. Compile-Time Verification

var _ Repository = (*TodoRepository)(nil)
//   ↑ Interface   ↑ Implementation

This code makes the compiler verify that TodoRepository correctly implements the Repository interface.

2. Real-World Scenario

// Repository interface
type Repository interface {
    Create(ctx context.Context, userID int, input CreateTodoInput) (*Todo, error)
    GetByID(ctx context.Context, todoID, userID int) (*Todo, error)
    Update(ctx context.Context, todoID, userID int, input UpdateTodoInput) (*Todo, error)
}

// TodoRepository implementation
type TodoRepository struct {
    db *pgxpool.Pool
}

func (r *TodoRepository) Create(...) (*Todo, error) { ... }
func (r *TodoRepository) GetByID(...) (*Todo, error) { ... }
// func (r *TodoRepository) Update(...) (*Todo, error) { ... }  ← Forgot to implement!

// Compile-time verification
var _ Repository = (*TodoRepository)(nil)
// Compile error: TodoRepository does not implement Repository (missing Update method)

3. Error Discovery Timing

❌ Without verification:

func main() {
    var repo Repository = &TodoRepository{}  // Runtime error
    repo.Update(...)  // Panics because method doesn't exist!
}

✅ With verification:

var _ Repository = (*TodoRepository)(nil)  // Error found at compile time
// Compiler: "Update method is missing!"

Syntax Breakdown

var _ Repository = (*TodoRepository)(nil)
//  ↑     ↑           ↑              ↑
//  1     2           3              4
  1. var _: Blank identifier (value not used)
  2. Repository: Interface type
  3. (*TodoRepository): Cast to pointer type
  4. (nil): nil value (actual instance not needed)

Why Use nil?

// No actual instance needed (only for type checking)
var _ Repository = (*TodoRepository)(nil)  // ✅ Lightweight and efficient

// No need to do this
var _ Repository = &TodoRepository{db: nil}  // ❌ Unnecessary instance creation

Other Examples

// Also widely used in standard library
var _ io.Writer = (*os.File)(nil)
var _ fmt.Stringer = (*time.Time)(nil)
var _ error = (*os.PathError)(nil)

// In our project
var _ Repository = (*TodoRepository)(nil)
var _ auth.UserRepositoryInterface = (*auth.UserRepository)(nil)

Real Benefits

// When interface changes
type Repository interface {
    Create(...) (*Todo, error)
    GetByID(...) (*Todo, error)
    Delete(ctx context.Context, todoID, userID int) error  // ← New method added
}

// If Delete method not implemented in TodoRepository
var _ Repository = (*TodoRepository)(nil)  // Immediate compile error!

Conclusion: This single line acts as a safety net that makes the compiler verify interface implementation completeness in advance!

Summary

In this guide, we've covered essential Go patterns that improve code safety, maintainability, and efficiency:

  1. Struct Tags: Enable seamless mapping between different systems (DB, JSON, validation)
  2. Error Wrapping (%w vs %v): Maintain error chains for better debugging and error handling
  3. Pointer Safety: Double-check pattern prevents nil pointer panics
  4. SQL Parameterization: Dynamic query building with proper parameter indexing prevents SQL injection
  5. Compile-Time Verification: Ensure interface implementations are complete before runtime

These patterns represent Go best practices used in production-grade applications. Mastering them will significantly improve your Go backend development skills.

Next Steps

  • Practice implementing these patterns in your own Go projects
  • Study the Go standard library to see these patterns in action
  • Explore error handling libraries like github.com/pkg/errors
  • Learn more about Go's type system and interface composition

Happy coding! 🚀