Following the Learn Go with Tests guide, exploring Go’s built-in key-value data structure: maps.
Maps #
dictionary := map[string]string{"test": "this is just a test"}Map syntax: map[KeyType]ValueType{key: value}
Key constraints:
Keys must be comparable types. Go’s comparison operators (==, !=) must be able to compare two values of the key type.
Compatible key types Examples:
| Type Category | Examples | Comparison Rules |
|---|---|---|
| Boolean | bool |
Equal if both true or both false |
| Integer | int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64 |
Compared numerically |
| Floating-point | float32, float64 |
Compared per IEEE 754 standard |
| Complex | complex64, complex128 |
Equal if both real and imaginary parts are equal |
| String | string |
Compared lexically byte-wise |
| Struct | struct{...} |
Comparable if all fields are comparable; equal if all corresponding non-blank fields are equal |
| Array | [n]T |
Comparable if element type is comparable; equal if all corresponding elements are equal |
More here (Go Language Specification - Comparison operators)
Incompatible key types:
| Type | Comparable ? |
|---|---|
Slices ([]T) |
Not comparable |
Maps (map[K]V) |
Not comparable |
Functions (func(...)) |
Not comparable |
Values: Can be any type, including other maps
Search #
func Search(dictionary map[string]string, word string) string {
return dictionary[word] // Access value by key
}Map access: map[key] returns the value for that key.
Custom Type #
Instead of passing map[string]string everywhere, create a custom type:
type Dictionary map[string]string
func (d Dictionary) Search(word string) string {
return d[word]
}
// Usage becomes cleaner:
dictionary := Dictionary{"test": "this is just a test"}
result := dictionary.Search("test")Benefits of custom types:
- More expressive API
- Attach methods to the type
- Hide implementation details
Missing Keys #
Maps return two values when accessed:
func (d Dictionary) Search(word string) (string, error) {
definition, ok := d[word] // ok is true if key exists
if !ok {
return "", errors.New("could not find the word you were looking for")
}
return definition, nil
}Key insight: value, exists := map[key] is the idiomatic way to check if a key exists.
Adding #
func (d Dictionary) Add(word, definition string) {
d[word] = definition // Simple assignment
}Map Pointer #
Important: Maps are reference types - you can modify them without passing pointers:
func modifyMap(m map[string]string) {
m["new"] = "value" // This modifies the original map
}
func main() {
myMap := map[string]string{"existing": "value"}
modifyMap(myMap)
fmt.Println(myMap) // Prints: map[existing:value new:value]
}Why? Map values are pointers to the underlying hash table structure. So no need to pass them as * pointers explicitly
Nil vs Empty Maps #
Dangerous: var m map[string]string creates a nil map (panic on write)
Safe initialization:
// Method 1: literal syntax
var dictionary = map[string]string{}
// Method 2: make function
var dictionary = make(map[string]string)
// Both create an empty, writeable mapError Comparison #
var ErrWordExists = errors.New("cannot add word because it already exists")
func (d Dictionary) Add(word, definition string) error {
_, err := d.Search(word)
switch err {
case ErrNotFound:
d[word] = definition
return nil
case nil:
return ErrWordExists // Word already exists
default:
return err // Unexpected error
}
}Switch on errors provides clean error handling with safety nets.
errors.Is
#
Important: For production code, use errors.Is instead of direct error comparison:
import "errors"
func (d Dictionary) Add(word, definition string) error {
_, err := d.Search(word)
switch {
case errors.Is(err, ErrNotFound):
d[word] = definition
return nil
case err == nil:
return ErrWordExists // Word already exists
default:
return err // Unexpected error
}
}Why errors.Is is better:
- Wrapped error support - Works with
fmt.Errorf("context: %w", err)and error wrapping - Future-proof - Handles custom error types that implement
Is(error) boolmethod - Chain traversal - Automatically unwraps error chains to find the target error
Example of why direct comparison fails:
baseErr := ErrNotFound
wrappedErr := fmt.Errorf("database error: %w", baseErr)
// Direct comparison fails
if wrappedErr == ErrNotFound { // false - different pointers
// This code never runs
}
// errors.Is succeeds
if errors.Is(wrappedErr, ErrNotFound) { // true - unwraps and finds ErrNotFound
// This code runs correctly
}Custom Error Types #
For better error handling, create custom error types:
type DictionaryErr string
// Error implements the built-in error interface
func (e DictionaryErr) Error() string {
return string(e)
}
const (
ErrNotFound = DictionaryErr(
"could not find the word you were looking for")
ErrWordExists = DictionaryErr(
"cannot add word because it already exists")
ErrWordDoesNotExist = DictionaryErr(
"cannot update word because it does not exist")
)Updates #
func (d Dictionary) Update(word, definition string) error {
_, err := d.Search(word)
switch err {
case ErrNotFound:
return ErrWordDoesNotExist // Can't update non-existent word
case nil:
d[word] = definition // Word exists, update it
return nil
default:
return err
}
}Deletions #
Go provides a built-in delete function:
func (d Dictionary) Delete(word string) error {
_, err := d.Search(word)
switch err {
case ErrNotFound:
return ErrWordDoesNotExist
case nil:
delete(d, word) // Built-in delete function
return nil
default:
return err
}
}Delete function: delete(map, key) removes the key-value pair.
Operations Summary #
| Operation | Syntax | Notes |
|---|---|---|
| Create | map[string]string{} or make(map[string]string) |
Use these, not var m map[string]string |
| Read | value, exists := m[key] |
Check exists to avoid zero values |
| Write | m[key] = value |
Creates or updates |
| Delete | delete(m, key) |
Safe to call on non-existent keys |
| Length | len(m) |
Number of key-value pairs |