Go Advanced Patterns: Struct Tags, Error Handling, and Type Safety
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.
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 niljson:"-": Completely exclude from JSON serializationvalidate:"required,max=500": For validation libraries
When to Use Which Tags
| Use Case | Tag | Example |
|---|---|---|
| Database ORM/Driver | db: | db:"user_id" |
| REST API | json: | json:"user_id" |
| Form Processing | form: | form:"username" |
| Validation | validate: | validate:"required" |
| GraphQL | graphql: | 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:
- Explicit Optional:
nil= "no search",""= "search for empty string" - JSON Serialization:
omitemptyexcludes field from JSON if nil - 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
var _: Blank identifier (value not used)Repository: Interface type(*TodoRepository): Cast to pointer type(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:
- Struct Tags: Enable seamless mapping between different systems (DB, JSON, validation)
- Error Wrapping (
%wvs%v): Maintain error chains for better debugging and error handling - Pointer Safety: Double-check pattern prevents nil pointer panics
- SQL Parameterization: Dynamic query building with proper parameter indexing prevents SQL injection
- 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! 🚀