Back to blog

Go Phase 2: Core Concepts - Types, Interfaces & Error Handling

gogolangprogrammingbackendfundamentals
Go Phase 2: Core Concepts - Types, Interfaces & Error Handling

Welcome to Phase 2 of the Go Learning Roadmap! In Phase 1, you learned Go's syntax — variables, functions, control flow, slices, maps, and structs. Now it's time to learn the concepts that make Go programs well-structured: how to organize behavior with methods and interfaces, handle errors properly, and structure your code into packages.

These are the building blocks of every real Go project. Master them here, and Phase 3 (concurrency) will click much faster.

What You'll Learn

✅ Define methods on structs — value receivers vs pointer receivers
✅ Use interfaces for polymorphism — Go's implicit implementation model
✅ Handle errors idiomatically — the error interface, custom errors, wrapping
✅ Organize code into packages — exported vs unexported, naming conventions
✅ Manage dependencies with Go modules — go.mod, go get, go mod tidy
✅ Work with key standard library packages — fmt, strings, strconv, os, io


Part 1: Structs and Methods

You saw structs briefly in Phase 1. Now let's go deeper — structs are Go's primary way to define custom types and attach behavior.

Defining Structs

type User struct {
    ID        int
    FirstName string
    LastName  string
    Email     string
    Age       int
    Active    bool
}
 
// Create instances
u1 := User{ID: 1, FirstName: "Alice", LastName: "Smith", Email: "alice@example.com", Age: 30, Active: true}
u2 := User{FirstName: "Bob"} // Zero values for unset fields: ID=0, Age=0, Active=false

Methods

A method is a function with a receiver — it's attached to a type:

// Method on User — value receiver
func (u User) FullName() string {
    return u.FirstName + " " + u.LastName
}
 
// Method on User — pointer receiver (can modify the struct)
func (u *User) Deactivate() {
    u.Active = false
}
 
func main() {
    user := User{FirstName: "Alice", LastName: "Smith", Active: true}
    fmt.Println(user.FullName())  // "Alice Smith"
 
    user.Deactivate()
    fmt.Println(user.Active)      // false
}

Value Receiver vs Pointer Receiver

This is one of the most important decisions in Go:

AspectValue Receiver (u User)Pointer Receiver (u *User)
Modifies original?No — works on a copyYes — modifies the original
Use whenRead-only methods, small structsMethods that mutate state, large structs
ConventionGetters, string formattingSetters, state changes
type Counter struct {
    count int
}
 
// Value receiver — returns a new value, doesn't modify original
func (c Counter) Value() int {
    return c.count
}
 
// Pointer receiver — modifies the original struct
func (c *Counter) Increment() {
    c.count++
}
 
func main() {
    c := Counter{}
    c.Increment()
    c.Increment()
    fmt.Println(c.Value()) // 2
}

Rule of thumb: If any method on a type uses a pointer receiver, all methods on that type should use pointer receivers. This keeps the API consistent. For a deep dive, see Pointer Receiver vs Value Receiver.

Struct Embedding (Composition)

Go doesn't have inheritance. Instead, you embed one struct in another to reuse fields and methods:

type Address struct {
    Street string
    City   string
    State  string
    Zip    string
}
 
func (a Address) Format() string {
    return fmt.Sprintf("%s, %s, %s %s", a.Street, a.City, a.State, a.Zip)
}
 
type Employee struct {
    Name    string
    Role    string
    Address // Embedded — Employee "inherits" Address fields and methods
}
 
func main() {
    e := Employee{
        Name: "Alice",
        Role: "Engineer",
        Address: Address{
            Street: "123 Main St",
            City:   "Portland",
            State:  "OR",
            Zip:    "97201",
        },
    }
 
    // Access embedded fields directly
    fmt.Println(e.City)      // "Portland" — promoted from Address
    fmt.Println(e.Format())  // "123 Main St, Portland, OR 97201" — promoted method
}

Key insight: Embedding is composition, not inheritance. The Employee has an Address, it is not an Address. For patterns and trade-offs, see Favor Composition Over Inheritance.


Part 2: Interfaces

Interfaces are Go's most powerful abstraction tool — and they work completely differently from Java or TypeScript.

Implicit Implementation

In Go, a type implements an interface automatically if it has all the required methods. No implements keyword needed:

// Define an interface
type Writer interface {
    Write(data []byte) (int, error)
}
 
// FileWriter implements Writer — no explicit declaration needed
type FileWriter struct {
    path string
}
 
func (fw FileWriter) Write(data []byte) (int, error) {
    fmt.Printf("Writing %d bytes to %s\n", len(data), fw.path)
    return len(data), nil
}
 
// ConsoleWriter also implements Writer
type ConsoleWriter struct{}
 
func (cw ConsoleWriter) Write(data []byte) (int, error) {
    fmt.Print(string(data))
    return len(data), nil
}
 
// Function accepts anything that implements Writer
func SaveReport(w Writer, report string) error {
    _, err := w.Write([]byte(report))
    return err
}
 
func main() {
    // Both work — Go checks at compile time
    SaveReport(FileWriter{path: "/tmp/report.txt"}, "Q4 Results")
    SaveReport(ConsoleWriter{}, "Q4 Results")
}

No implements keyword. If the methods match, the type satisfies the interface. This is called structural typing — it's what makes Go interfaces so flexible.

Common Interface Patterns

Small Interfaces Are Better

Go's standard library favors tiny interfaces:

// One method — incredibly useful
type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}
 
type Closer interface {
    Close() error
}
 
// Compose small interfaces into larger ones
type ReadWriter interface {
    Reader
    Writer
}
 
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

"The bigger the interface, the weaker the abstraction." — Rob Pike. Keep interfaces small (1-3 methods). Compose them when needed.

Interface as Parameter

Accept interfaces, return concrete types:

type Storage interface {
    Save(key string, data []byte) error
    Load(key string) ([]byte, error)
}
 
// MemoryStorage implements Storage
type MemoryStorage struct {
    data map[string][]byte
}
 
func NewMemoryStorage() *MemoryStorage {
    return &MemoryStorage{data: make(map[string][]byte)}
}
 
func (m *MemoryStorage) Save(key string, data []byte) error {
    m.data[key] = data
    return nil
}
 
func (m *MemoryStorage) Load(key string) ([]byte, error) {
    data, ok := m.data[key]
    if !ok {
        return nil, fmt.Errorf("key not found: %s", key)
    }
    return data, nil
}
 
// Accept interface — testable and flexible
func ProcessData(store Storage, key string) error {
    data := []byte("processed data")
    return store.Save(key, data)
}

The Empty Interface and any

The empty interface interface{} (or its alias any since Go 1.18) accepts any type:

func PrintAnything(v any) {
    fmt.Printf("Type: %T, Value: %v\n", v, v)
}
 
PrintAnything(42)          // Type: int, Value: 42
PrintAnything("hello")     // Type: string, Value: hello
PrintAnything(true)        // Type: bool, Value: true

Use any sparingly. It bypasses type safety. Prefer specific interfaces or generics (Go 1.18+) when possible.

Type Assertions and Type Switches

When you receive an interface value, you can check its underlying type:

func Describe(i any) string {
    // Type switch — check multiple types
    switch v := i.(type) {
    case string:
        return fmt.Sprintf("string of length %d", len(v))
    case int:
        return fmt.Sprintf("integer: %d", v)
    case bool:
        return fmt.Sprintf("boolean: %t", v)
    default:
        return fmt.Sprintf("unknown type: %T", v)
    }
}
 
// Type assertion — extract specific type
func MustBeString(i any) string {
    s, ok := i.(string) // safe assertion — doesn't panic
    if !ok {
        return "not a string"
    }
    return s
}

Interface Nil Gotcha

One of Go's most confusing behaviors — an interface value is only nil when both its type and value are nil:

type MyError struct {
    msg string
}
 
func (e *MyError) Error() string { return e.msg }
 
func DoSomething() error {
    var err *MyError = nil // typed nil pointer
    return err             // ⚠️ NOT nil! Interface has type (*MyError) with nil value
}
 
func main() {
    err := DoSomething()
    if err != nil {
        // This runs! Even though the underlying pointer is nil
        fmt.Println("Error:", err) // panic: nil pointer dereference
    }
}
 
// ✅ Fix: return nil directly
func DoSomethingFixed() error {
    var err *MyError = nil
    if err != nil {
        return err
    }
    return nil // Return untyped nil — interface is truly nil
}

Part 3: Error Handling

Go doesn't have exceptions. Instead, errors are values — functions return them, callers check them. This is deliberate: it forces you to think about every failure point.

The error Interface

The built-in error interface is just:

type error interface {
    Error() string
}

Any type with an Error() string method is an error.

Basic Error Handling

import (
    "errors"
    "fmt"
    "os"
    "strconv"
)
 
// Functions return errors as the last return value
func ReadConfig(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err // propagate the error
    }
    return string(data), nil
}
 
func main() {
    config, err := ReadConfig("/etc/app.conf")
    if err != nil {
        fmt.Println("Failed to read config:", err)
        return
    }
    fmt.Println("Config:", config)
}

The if err != nil pattern is Go's signature. You'll write it hundreds of times. It's verbose, but explicit — you always know exactly where errors are handled.

Creating Errors

import (
    "errors"
    "fmt"
)
 
// Simple error with errors.New
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
 
// Formatted error with fmt.Errorf
func ValidateAge(age int) error {
    if age < 0 {
        return fmt.Errorf("invalid age: %d (must be non-negative)", age)
    }
    if age > 150 {
        return fmt.Errorf("invalid age: %d (must be <= 150)", age)
    }
    return nil
}

Custom Error Types

For errors that carry structured data:

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}
 
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
 
type NotFoundError struct {
    Resource string
    ID       string
}
 
func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %s not found", e.Resource, e.ID)
}
 
// Return custom errors
func FindUser(id string) (*User, error) {
    // ... database lookup
    return nil, &NotFoundError{Resource: "User", ID: id}
}
 
// Check for specific error types
func HandleRequest(id string) {
    user, err := FindUser(id)
    if err != nil {
        var notFound *NotFoundError
        if errors.As(err, &notFound) {
            fmt.Printf("404: %s %s not found\n", notFound.Resource, notFound.ID)
            return
        }
        fmt.Println("500: Internal error:", err)
        return
    }
    fmt.Println("Found user:", user)
}

Error Wrapping (Go 1.13+)

Wrap errors with context using fmt.Errorf and %w:

func ReadUserConfig(userID string) (Config, error) {
    path := fmt.Sprintf("/home/%s/.config", userID)
 
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap with context — original error is preserved
        return Config{}, fmt.Errorf("reading config for user %s: %w", userID, err)
    }
 
    config, err := parseConfig(data)
    if err != nil {
        return Config{}, fmt.Errorf("parsing config for user %s: %w", userID, err)
    }
 
    return config, nil
}
 
// Unwrap to check the original error
func main() {
    config, err := ReadUserConfig("alice")
    if err != nil {
        // errors.Is checks the entire error chain
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Config file doesn't exist — using defaults")
            return
        }
        // errors.As extracts a specific type from the chain
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("Path error on %s: %v\n", pathErr.Path, pathErr.Err)
            return
        }
        fmt.Println("Unexpected error:", err)
    }
}

Sentinel Errors

Pre-defined errors for common conditions:

import "errors"
 
// Define sentinel errors at package level
var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("conflict")
)
 
func GetUser(id string) (*User, error) {
    // ... lookup
    return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
 
func main() {
    _, err := GetUser("123")
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found") // works even when wrapped
    }
}

Error Handling Summary

FunctionPurposeExample
errors.New(msg)Create simple errorerrors.New("something failed")
fmt.Errorf("...: %w", err)Wrap error with contextfmt.Errorf("reading file: %w", err)
errors.Is(err, target)Check if error matches (checks chain)errors.Is(err, os.ErrNotExist)
errors.As(err, &target)Extract typed error (checks chain)errors.As(err, &pathErr)

Part 4: Packages and Visibility

Packages are Go's way of organizing code. Every Go file belongs to a package, and the package system controls what's visible to other code.

Package Basics

myproject/
├── go.mod
├── main.go            // package main
├── user/
│   ├── user.go        // package user
│   └── validation.go  // package user
└── store/
    └── memory.go      // package store
// user/user.go
package user
 
// Exported (uppercase) — visible outside the package
type User struct {
    ID    int
    Name  string
    email string // unexported (lowercase) — only visible within package user
}
 
// Exported function
func New(id int, name, email string) User {
    return User{ID: id, Name: name, email: email}
}
 
// Exported method
func (u User) DisplayName() string {
    return u.Name
}
 
// unexported method — only callable within package user
func (u User) emailDomain() string {
    // ...
    return ""
}
// main.go
package main
 
import (
    "fmt"
    "myproject/user"
)
 
func main() {
    u := user.New(1, "Alice", "alice@example.com")
    fmt.Println(u.Name)          // ✅ exported field
    fmt.Println(u.DisplayName()) // ✅ exported method
    // fmt.Println(u.email)      // ❌ compile error — unexported field
    // fmt.Println(u.emailDomain()) // ❌ compile error — unexported method
}

Visibility Rules

Name starts withVisibilityExample
UppercaseExported (public)User, New(), ID
lowercaseUnexported (package-private)email, emailDomain()

No public/private keywords. Just capitalization. It's simple, and it's enforced by the compiler.

Package Naming Conventions

// ✅ Good package names — short, lowercase, one word
package user
package store
package http
package json
 
// ❌ Bad package names
package userManager    // don't use camelCase
package user_service   // don't use underscores
package util           // too generic — what does it do?
package helpers        // too generic
package common         // too generic

Avoid utils, helpers, common packages. They become dumping grounds. Name packages after what they provide, not how they're used.

The init() Function

Each package can have init() functions that run automatically at startup:

package database
 
import "fmt"
 
var connectionPool *Pool
 
func init() {
    // Runs automatically when the package is imported
    connectionPool = NewPool(10)
    fmt.Println("Database pool initialized")
}

Use init() sparingly — it runs before main() and can make program startup hard to reason about. Prefer explicit initialization.


Part 5: Go Modules

Go modules are the standard way to manage dependencies and versioning.

Creating a Module

# Create a new module
mkdir myproject && cd myproject
go mod init github.com/username/myproject
 
# This creates go.mod:
# module github.com/username/myproject
# go 1.22

go.mod File

module github.com/username/myproject
 
go 1.22
 
require (
    github.com/gin-gonic/gin v1.9.1
    github.com/lib/pq v1.10.9
)
 
require (
    // indirect dependencies (auto-managed)
    golang.org/x/net v0.17.0 // indirect
)

Essential Module Commands

# Add a dependency
go get github.com/gin-gonic/gin
 
# Add a specific version
go get github.com/lib/pq@v1.10.9
 
# Remove unused dependencies and add missing ones
go mod tidy
 
# Download all dependencies to local cache
go mod download
 
# List all dependencies
go list -m all
 
# Check for available updates
go list -m -u all
 
# Update a dependency
go get -u github.com/gin-gonic/gin

go.sum File

go.sum contains cryptographic hashes of dependencies. Always commit it — it ensures reproducible builds:

github.com/gin-gonic/gin v1.9.1 h1:4+fr/el88...
github.com/gin-gonic/gin v1.9.1/go.mod h1:2fq3...

Module Best Practices

PracticeWhy
Run go mod tidy before committingRemoves unused deps, adds missing ones
Commit both go.mod and go.sumEnsures reproducible builds
Use specific versionsAvoid breaking changes from @latest
Regularly update dependenciesSecurity patches and bug fixes

Part 6: Standard Library Essentials

Go has a rich standard library. Here are the packages you'll use most:

fmt — Formatted I/O

name := "Alice"
age := 30
 
// Print functions
fmt.Println("Hello, World!")                    // with newline
fmt.Printf("Name: %s, Age: %d\n", name, age)   // formatted
fmt.Print("No newline")                          // no newline
 
// Sprint — format to string (instead of printing)
msg := fmt.Sprintf("Hello, %s! You are %d.", name, age)
 
// Common format verbs
fmt.Printf("%v\n", user)     // default format
fmt.Printf("%+v\n", user)    // with field names: {ID:1 Name:Alice}
fmt.Printf("%#v\n", user)    // Go-syntax: main.User{ID:1, Name:"Alice"}
fmt.Printf("%T\n", user)     // type: main.User
fmt.Printf("%d\n", 42)       // integer
fmt.Printf("%f\n", 3.14)     // float
fmt.Printf("%s\n", "hello")  // string
fmt.Printf("%q\n", "hello")  // quoted string: "hello"
fmt.Printf("%x\n", 255)      // hex: ff
fmt.Printf("%p\n", &user)    // pointer address

strings — String Manipulation

import "strings"
 
s := "Hello, World!"
 
strings.Contains(s, "World")           // true
strings.HasPrefix(s, "Hello")          // true
strings.HasSuffix(s, "!")              // true
strings.ToUpper(s)                     // "HELLO, WORLD!"
strings.ToLower(s)                     // "hello, world!"
strings.TrimSpace("  hello  ")         // "hello"
strings.Split("a,b,c", ",")           // ["a", "b", "c"]
strings.Join([]string{"a", "b"}, "-")  // "a-b"
strings.Replace(s, "World", "Go", 1)   // "Hello, Go!"
strings.Count(s, "l")                  // 3
strings.Index(s, "World")              // 7

strconv — String Conversions

import "strconv"
 
// String to number
i, err := strconv.Atoi("42")            // int: 42
f, err := strconv.ParseFloat("3.14", 64) // float64: 3.14
b, err := strconv.ParseBool("true")      // bool: true
 
// Number to string
s := strconv.Itoa(42)                    // "42"
s = strconv.FormatFloat(3.14, 'f', 2, 64) // "3.14"
s = strconv.FormatBool(true)              // "true"

os — Operating System Interaction

import "os"
 
// Environment variables
home := os.Getenv("HOME")
port := os.Getenv("PORT")
 
// File operations
data, err := os.ReadFile("config.json")  // read entire file
err = os.WriteFile("output.txt", []byte("hello"), 0644) // write file
 
// Open file for more control
file, err := os.Open("data.txt")         // read-only
file, err = os.Create("new.txt")          // create/truncate
defer file.Close()
 
// Command-line arguments
args := os.Args    // os.Args[0] is the program name
if len(args) > 1 {
    fmt.Println("First arg:", args[1])
}
 
// Exit
os.Exit(1) // exit with status code

io — I/O Primitives

import (
    "io"
    "os"
    "strings"
)
 
// io.Reader and io.Writer are the core abstractions
// Many types implement them: files, network connections, buffers, etc.
 
// Copy from reader to writer
src := strings.NewReader("Hello, World!")
dst := os.Stdout
io.Copy(dst, src) // prints "Hello, World!"
 
// Read all bytes from a reader
data, err := io.ReadAll(strings.NewReader("data"))

Best Practices Summary

Structs & Methods

PracticeExample
Use pointer receivers if any method mutatesAll methods on the type should use *T
Provide constructor functionsfunc NewUser(name string) *User
Keep structs focusedOne responsibility per struct
Embed for composition, not inheritancetype Admin struct { User }

Interfaces

PracticeExample
Keep interfaces small (1-3 methods)type Reader interface { Read(p []byte) (int, error) }
Define interfaces where they're used, not where they're implementedConsumer package defines the interface
Accept interfaces, return concrete typesfunc NewServer(store Storage) *Server
Don't create interfaces prematurelyWait until you have 2+ implementations

Error Handling

PracticeExample
Always check returned errorsif err != nil { return err }
Add context when wrappingfmt.Errorf("reading user %s: %w", id, err)
Use sentinel errors for expected conditionsvar ErrNotFound = errors.New("not found")
Use custom types for structured errorstype ValidationError struct { ... }

Packages

PracticeExample
Short, lowercase, one-word namesuser, store, auth
Avoid utils/helpers packagesName after what it provides
Keep init() simple or avoid itPrefer explicit initialization
Run go mod tidy before committingClean up unused dependencies

Common Mistakes

1. Returning Typed Nil (Interface Gotcha)

// BAD: Returns non-nil interface with nil value
func GetError() error {
    var err *MyError = nil
    return err // interface{type: *MyError, value: nil} — NOT nil!
}
 
// GOOD: Return untyped nil
func GetError() error {
    var err *MyError = nil
    if err != nil {
        return err
    }
    return nil // truly nil interface
}

2. Ignoring Errors

// BAD: Silently ignoring errors
data, _ := os.ReadFile("config.json") // What if the file doesn't exist?
 
// GOOD: Handle or at minimum log
data, err := os.ReadFile("config.json")
if err != nil {
    log.Fatal("failed to read config:", err)
}

3. Overly Large Interfaces

// BAD: Kitchen-sink interface — hard to implement, hard to test
type UserService interface {
    Create(u User) error
    Update(u User) error
    Delete(id string) error
    Find(id string) (*User, error)
    List() ([]User, error)
    SendEmail(id, subject, body string) error
    ResetPassword(id string) error
    GenerateReport() ([]byte, error)
}
 
// GOOD: Small, focused interfaces
type UserReader interface {
    Find(id string) (*User, error)
    List() ([]User, error)
}
 
type UserWriter interface {
    Create(u User) error
    Update(u User) error
    Delete(id string) error
}

4. Not Using errors.Is / errors.As

// BAD: Direct comparison breaks with wrapped errors
if err == os.ErrNotExist {
    // Won't match if err was wrapped with fmt.Errorf("%w", ...)
}
 
// GOOD: errors.Is checks the entire error chain
if errors.Is(err, os.ErrNotExist) {
    // Works even with wrapped errors
}

Practice Exercises

Exercise 1: Shape Calculator

Build a shape calculator using interfaces:

type Shape interface {
    Area() float64
    Perimeter() float64
    String() string
}

Implement Circle, Rectangle, and Triangle. Write a PrintShapeInfo(s Shape) function that works with any shape.

Solution:

package main
 
import (
    "fmt"
    "math"
)
 
type Shape interface {
    Area() float64
    Perimeter() float64
    String() string
}
 
type Circle struct {
    Radius float64
}
 
func (c Circle) Area() float64      { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }
func (c Circle) String() string     { return fmt.Sprintf("Circle(r=%.1f)", c.Radius) }
 
type Rectangle struct {
    Width, Height float64
}
 
func (r Rectangle) Area() float64      { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
func (r Rectangle) String() string {
    return fmt.Sprintf("Rectangle(%.1f x %.1f)", r.Width, r.Height)
}
 
type Triangle struct {
    A, B, C float64 // sides
}
 
func (t Triangle) Area() float64 {
    s := t.Perimeter() / 2 // semi-perimeter (Heron's formula)
    return math.Sqrt(s * (s - t.A) * (s - t.B) * (s - t.C))
}
 
func (t Triangle) Perimeter() float64 { return t.A + t.B + t.C }
func (t Triangle) String() string {
    return fmt.Sprintf("Triangle(%.1f, %.1f, %.1f)", t.A, t.B, t.C)
}
 
func PrintShapeInfo(s Shape) {
    fmt.Printf("%s → Area: %.2f, Perimeter: %.2f\n", s, s.Area(), s.Perimeter())
}
 
func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Rectangle{Width: 4, Height: 6},
        Triangle{A: 3, B: 4, C: 5},
    }
 
    for _, s := range shapes {
        PrintShapeInfo(s)
    }
}
// Circle(r=5.0) → Area: 78.54, Perimeter: 31.42
// Rectangle(4.0 x 6.0) → Area: 24.00, Perimeter: 20.00
// Triangle(3.0, 4.0, 5.0) → Area: 6.00, Perimeter: 12.00

Exercise 2: Error Handling Chain

Build a user registration function that validates input and returns proper errors:

// Implement these:
// - ValidateEmail(email string) error
// - ValidatePassword(password string) error
// - RegisterUser(name, email, password string) (*User, error)
//
// Use custom error types, error wrapping, and errors.As to handle them.

Solution:

package main
 
import (
    "errors"
    "fmt"
    "strings"
    "unicode"
)
 
type ValidationError struct {
    Field   string
    Message string
}
 
func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
 
var ErrUserExists = errors.New("user already exists")
 
func ValidateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{Field: "email", Message: "must contain @"}
    }
    if !strings.Contains(email, ".") {
        return &ValidationError{Field: "email", Message: "must contain a domain"}
    }
    return nil
}
 
func ValidatePassword(password string) error {
    if len(password) < 8 {
        return &ValidationError{Field: "password", Message: "must be at least 8 characters"}
    }
    hasUpper := false
    hasDigit := false
    for _, r := range password {
        if unicode.IsUpper(r) { hasUpper = true }
        if unicode.IsDigit(r) { hasDigit = true }
    }
    if !hasUpper {
        return &ValidationError{Field: "password", Message: "must contain an uppercase letter"}
    }
    if !hasDigit {
        return &ValidationError{Field: "password", Message: "must contain a digit"}
    }
    return nil
}
 
type User struct {
    Name  string
    Email string
}
 
var existingUsers = map[string]bool{"alice@example.com": true}
 
func RegisterUser(name, email, password string) (*User, error) {
    if err := ValidateEmail(email); err != nil {
        return nil, fmt.Errorf("registration failed: %w", err)
    }
    if err := ValidatePassword(password); err != nil {
        return nil, fmt.Errorf("registration failed: %w", err)
    }
    if existingUsers[email] {
        return nil, fmt.Errorf("registration for %s: %w", email, ErrUserExists)
    }
 
    user := &User{Name: name, Email: email}
    existingUsers[email] = true
    return user, nil
}
 
func main() {
    // Test cases
    testCases := []struct{ name, email, password string }{
        {"Alice", "alice", "Pass1234"},               // bad email
        {"Bob", "bob@test.com", "short"},              // bad password
        {"Carol", "alice@example.com", "Pass1234"},    // duplicate
        {"Dave", "dave@test.com", "Secure1234"},       // success
    }
 
    for _, tc := range testCases {
        user, err := RegisterUser(tc.name, tc.email, tc.password)
        if err != nil {
            var valErr *ValidationError
            if errors.As(err, &valErr) {
                fmt.Printf("Validation error on %s: %s\n", valErr.Field, valErr.Message)
            } else if errors.Is(err, ErrUserExists) {
                fmt.Printf("User %s already exists\n", tc.email)
            } else {
                fmt.Println("Unexpected error:", err)
            }
            continue
        }
        fmt.Printf("Registered: %s (%s)\n", user.Name, user.Email)
    }
}

Exercise 3: Simple Key-Value Store

Build a key-value store with a Storage interface, MemoryStore implementation, and a LoggingStore decorator that logs every operation:

type Storage interface {
    Get(key string) (string, error)
    Set(key string, value string) error
    Delete(key string) error
}

Implement MemoryStore and LoggingStore (wraps any Storage). Write a main() that demonstrates both.

Solution:

package main
 
import (
    "errors"
    "fmt"
    "time"
)
 
var ErrKeyNotFound = errors.New("key not found")
 
type Storage interface {
    Get(key string) (string, error)
    Set(key, value string) error
    Delete(key string) error
}
 
// MemoryStore — in-memory implementation
type MemoryStore struct {
    data map[string]string
}
 
func NewMemoryStore() *MemoryStore {
    return &MemoryStore{data: make(map[string]string)}
}
 
func (m *MemoryStore) Get(key string) (string, error) {
    val, ok := m.data[key]
    if !ok {
        return "", fmt.Errorf("get %q: %w", key, ErrKeyNotFound)
    }
    return val, nil
}
 
func (m *MemoryStore) Set(key, value string) error {
    m.data[key] = value
    return nil
}
 
func (m *MemoryStore) Delete(key string) error {
    if _, ok := m.data[key]; !ok {
        return fmt.Errorf("delete %q: %w", key, ErrKeyNotFound)
    }
    delete(m.data, key)
    return nil
}
 
// LoggingStore — decorator that logs operations
type LoggingStore struct {
    inner Storage
}
 
func NewLoggingStore(inner Storage) *LoggingStore {
    return &LoggingStore{inner: inner}
}
 
func (l *LoggingStore) Get(key string) (string, error) {
    start := time.Now()
    val, err := l.inner.Get(key)
    fmt.Printf("[GET] key=%q duration=%v err=%v\n", key, time.Since(start), err)
    return val, err
}
 
func (l *LoggingStore) Set(key, value string) error {
    start := time.Now()
    err := l.inner.Set(key, value)
    fmt.Printf("[SET] key=%q value=%q duration=%v err=%v\n", key, value, time.Since(start), err)
    return err
}
 
func (l *LoggingStore) Delete(key string) error {
    start := time.Now()
    err := l.inner.Delete(key)
    fmt.Printf("[DEL] key=%q duration=%v err=%v\n", key, time.Since(start), err)
    return err
}
 
func main() {
    var store Storage = NewLoggingStore(NewMemoryStore())
 
    store.Set("name", "Alice")
    store.Set("role", "admin")
 
    val, _ := store.Get("name")
    fmt.Println("Got:", val)
 
    store.Delete("role")
 
    _, err := store.Get("role")
    if errors.Is(err, ErrKeyNotFound) {
        fmt.Println("Key was deleted successfully")
    }
}

What's Next?

You now understand Go's type system, interfaces, error handling, and package organization — the core of every Go program.

In Phase 3: Concurrency & Advanced Patterns, you'll learn:

  • Goroutines and the Go scheduler
  • Channels for communication between goroutines
  • Select statements and patterns
  • sync package (Mutex, WaitGroup, Once)
  • Context for cancellation and timeouts

Related Posts:


Summary

In this post, you learned:

Structs & Methods: Define custom types, attach behavior with value and pointer receivers
Embedding: Compose types using struct embedding instead of inheritance
Interfaces: Implicit implementation, small interfaces, type assertions and switches
Error Handling: The error interface, custom errors, wrapping with %w, errors.Is/errors.As
Packages: Exported vs unexported, naming conventions, init() function
Go Modules: go.mod, go.sum, essential commands (go get, go mod tidy)
Standard Library: fmt, strings, strconv, os, io essentials

You're now ready to tackle Go's most distinctive feature — concurrency with goroutines and channels in Phase 3!


Series: Go Learning Roadmap
Previous: Phase 1: Go Fundamentals
Next: Phase 3: Concurrency & Advanced Patterns (coming soon)

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.