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=falseMethods
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:
| Aspect | Value Receiver (u User) | Pointer Receiver (u *User) |
|---|---|---|
| Modifies original? | No — works on a copy | Yes — modifies the original |
| Use when | Read-only methods, small structs | Methods that mutate state, large structs |
| Convention | Getters, string formatting | Setters, 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
Employeehas anAddress, it is not anAddress. 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
implementskeyword. 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: trueUse
anysparingly. 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 != nilpattern 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, ¬Found) {
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
| Function | Purpose | Example |
|---|---|---|
errors.New(msg) | Create simple error | errors.New("something failed") |
fmt.Errorf("...: %w", err) | Wrap error with context | fmt.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 with | Visibility | Example |
|---|---|---|
| Uppercase | Exported (public) | User, New(), ID |
| lowercase | Unexported (package-private) | email, emailDomain() |
No
public/privatekeywords. 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 genericAvoid
utils,helpers,commonpackages. 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.22go.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/gingo.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
| Practice | Why |
|---|---|
Run go mod tidy before committing | Removes unused deps, adds missing ones |
Commit both go.mod and go.sum | Ensures reproducible builds |
| Use specific versions | Avoid breaking changes from @latest |
| Regularly update dependencies | Security 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 addressstrings — 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") // 7strconv — 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 codeio — 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
| Practice | Example |
|---|---|
| Use pointer receivers if any method mutates | All methods on the type should use *T |
| Provide constructor functions | func NewUser(name string) *User |
| Keep structs focused | One responsibility per struct |
| Embed for composition, not inheritance | type Admin struct { User } |
Interfaces
| Practice | Example |
|---|---|
| Keep interfaces small (1-3 methods) | type Reader interface { Read(p []byte) (int, error) } |
| Define interfaces where they're used, not where they're implemented | Consumer package defines the interface |
| Accept interfaces, return concrete types | func NewServer(store Storage) *Server |
| Don't create interfaces prematurely | Wait until you have 2+ implementations |
Error Handling
| Practice | Example |
|---|---|
| Always check returned errors | if err != nil { return err } |
| Add context when wrapping | fmt.Errorf("reading user %s: %w", id, err) |
| Use sentinel errors for expected conditions | var ErrNotFound = errors.New("not found") |
| Use custom types for structured errors | type ValidationError struct { ... } |
Packages
| Practice | Example |
|---|---|
| Short, lowercase, one-word names | user, store, auth |
Avoid utils/helpers packages | Name after what it provides |
Keep init() simple or avoid it | Prefer explicit initialization |
Run go mod tidy before committing | Clean 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.00Exercise 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
syncpackage (Mutex, WaitGroup, Once)- Context for cancellation and timeouts
Related Posts:
- Go Learning Roadmap — Full series overview
- Pointer Receiver vs Value Receiver — Deep dive into method receivers
- Favor Composition Over Inheritance — Go's approach to code reuse
- Goroutines & Concurrency Fundamentals — Preview of Phase 3 topics
- Go Channels Communication — Channel deep dive
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.