Barbarian Meets Coding
barbarianmeetscoding

WebDev, UX & a Pinch of Fantasy

39 minutes readgo

Learn Go by Building a Command Line Todo App

The Go programming language logo
Learn the Go programming language by building a test-driven command line todo application

Go (or Golang) has been gaining significant popularity as a language for building fast, reliable, and efficient software. In this tutorial, we’ll learn Go by building a practical command line to-do application using test-driven development. By the end, you’ll have a solid understanding of Go’s syntax, tooling, and what makes it special compared to other languages like JavaScript.

Here’s a sneak peek of how you’ll interact with the todo app we’re building:

$ todo add "Learn how to exit Vim"
Added: Learn how to exit Vim

$ todo add "Train my cat to fetch coffee"
Added: Train my cat to fetch coffee

$ todo add "Figure out why my code works"
Added: Figure out why my code works

$ todo list
Todo List:
1. [ ] Learn how to exit Vim
2. [ ] Train my cat to fetch coffee
3. [ ] Figure out why my code works

$ todo complete 1
Marked item as completed

$ todo list
Todo List:
1. [] Learn how to exit Vim
2. [ ] Train my cat to fetch coffee
3. [ ] Figure out why my code works

$ todo -i
# Interactive mode activated...

Our CLI todo app will use a command-based interface (add, list, complete) that feels more natural and intuitive, while also supporting an interactive mode for managing todos through a simple prompt. Throughout the process of building this app, we’ll explore Go’s type system, error handling, file I/O, and command-line parsing—all the essentials for writing real-world Go applications.

Why Learn Go?

Go was designed at Google to address the challenges of large-scale software development. It offers:

  • Fast compilation and execution
  • Built-in concurrency
  • Strong static typing with simplicity
  • Excellent standard library
  • Great tooling out of the box
  • Cross-platform compatibility
  • Small binary sizes

Whether you’re building microservices, CLI tools, or backend services, Go provides an excellent balance of performance, simplicity, and developer productivity.

Setting Up Your Go Environment

Before we start, let’s set up our Go development environment:

Download and install Go

If you’re using macOS, you can install Go with Homebrew:

brew install go

For other platforms refer to golang.org.

Verify your installation

You can check if Go is installed correctly by running:

go version
# Which should output something like...
# go version go1.22.5 darwin/arm64

Set up your workspace

mkdir -p ~/go-todo-app
cd ~/go-todo-app

Creating a Go Module

Go modules provide dependency management and versioning in Go. Let’s create a module for our todo app:

go mod init github.com/yourusername/todo
# e.g. go mod init github.com/vintharas/todo

This creates a go.mod file that tracks your dependencies. Let’s examine it:

module github.com/yourusername/todo

go 1.21

As you add more dependencies, they will be listed in this file.

Project Structure

Go projects benefit from a well-organized structure that follows community conventions. Let’s set up a standardized project layout that will help keep our code organized as it grows:

todo/
├── cmd/               # Contains executable applications
│   └── todo/          # Our main CLI application
│       └── main.go    # Entry point with CLI parsing and user interaction
├── internal/          # Private application code that won't be imported by other projects
│   └── todo/          # Core domain logic for the todo list
│       ├── todo.go    # Todo list data structures and business logic
│       └── todo_test.go # Tests for our todo functionality
├── go.mod             # Module definition and dependency tracking
└── README.md          # Project documentation

This structure follows Go’s best practices and offers several benefits:

  • cmd/: Contains the entry points for executables. Each subdirectory is a separate executable, making it easy to build multiple related tools (like a command-line app, a server version, etc.) while sharing common code.

  • internal/: Designates code that’s private to our application. Go enforces that packages under “internal” cannot be imported by code outside the parent of the internal directory, providing encapsulation by design.

  • Separation of concerns: By keeping our executable code (cmd) separate from our business logic (internal), we create a cleaner architecture that’s easier to test and maintain. Our domain logic doesn’t need to know about CLI flags or user interaction.

  • Domain-driven directories: We organize by feature/domain (todo), not by technical layers (like “models” or “handlers”), making the codebase more navigable as it grows.

Let’s create these directories:

mkdir -p cmd/todo
mkdir -p internal/todo

As the project grows, we might add more directories like:

  • pkg/ for code that could be reused by external applications
  • api/ for API definitions (OpenAPI/Swagger specs, protocol buffers)
  • configs/ for configuration file templates or default configs
  • docs/ for detailed documentation and user guides

Test-Driven Development in Go

Go has a built-in testing framework in its standard library. Let’s start by writing our first test for the todo item model.

Create internal/todo/todo_test.go:

package todo

import (
 "testing"
)

func TestNewItem(t *testing.T) {
 item := NewItem("Learn Go")

 if item.Text != "Learn Go" {
  t.Errorf("Expected text to be 'Learn Go', got '%s'", item.Text)
 }

 if item.Done {
  t.Error("New item should not be marked as done")
 }
}

Running this test will fail because we haven’t implemented anything yet:

go test ./internal/todo
# github.com/vintharas/go-todo/internal/todo [github.com/vintharas/go-todo/internal/todo.test]
# internal/todo/todo_test.go:8:10: undefined: NewItem
# FAIL    github.com/vintharas/go-todo/internal/todo [build failed]
# FAIL

Now, let’s implement the code to make this test pass. Red. Green. Refactor!

Create internal/todo/todo.go:

package todo

// Item represents a todo item with text and completion status
type Item struct {
 Text string
 Done bool
}

// NewItem creates a new todo item with the specified text
func NewItem(text string) Item {
 return Item{
  Text: text,
  Done: false,
 }
}

Run the test again:

go test ./internal/todo
# ok      github.com/vintharas/go-todo/internal/todo      0.189s

Great! The test should now pass.

Understanding Go Basics: Code Walkthrough

Let’s take a step back and understand the Go language concepts we’ve introduced so far. This will help build a solid foundation as we continue developing our todo app.

Analyzing the Test File

Let’s analyze our test file line by line:

package todo          // 1. Package declarion

import (              // 2. Imports
 "testing"
)

func TestNewItem(t *testing.T) {  // 3. Test function
 item := NewItem("Learn Go")      // 4. Variable declaration and initialization

 if item.Text != "Learn Go" {     // 5. Conditional statement
  t.Errorf("Expected text to be 'Learn Go', got '%s'", item.Text)  // 6. Formatted error
 }

 if item.Done {
  t.Error("New item should not be marked as done") // 7. Error
 }
}

Every Go file starts with a package declaration (1). Files in the same directory typically belong to the same package. Packages are Go’s way of organizing and reusing code.

The import (2) statement brings in other packages that our code depends on. Here we’re importing the built-in testing package, which provides the framework for writing tests. You can use pkg.go.dev to discover new packages and read package documentation (like for the testing package).

In Go, test functions (3) must start with the word Test followed by a capitalized name. The function takes a single parameter of type *testing.T, which provides methods for reporting test failures. This is the way one interacts with the go testing framework.

The := syntax (4) is a shorthand for declaring and initializing variables. Go infers the type from the right-hand side. Here we’re calling our yet-to-be-implemented NewItem function. An equivalent alternative would be:

// short-hand variable declaration and initialization
item := NewItem("Learn Go")      // 4. Variable declaration and initialization
// "long-ass" variable declaration and initialization
var item Item = NewItem("Learn Go")

Conditional Statement (5) in go are very similar to other languages. It may look hard on the eye to see conditional statements inside a test, but this is a standard practice when writing tests in go using the standard testing library. Alternatives like testify provide a testing experience more similar to what one is accustomed in languages like JavaScript.

The testing library provides the Errorf (6) and Error (7) functions to fail a test with a formatted string or a simple string that describes what the problem is.

Go’s Testing Framework

Go’s approach to testing is straightforward:

  • Tests are regular Go functions with a specific naming pattern (Test followed by a capitalized name)
  • Test files are named with a _test.go suffix
  • The testing package provides tools for writing tests, including methods to report failures
  • Run tests with the go test command

When we run go test ./internal/todo, Go:

  1. Finds all test functions in files ending with _test.go in the specified package
  2. Runs each test function in a separate goroutine (Go’s lightweight thread)
  3. Reports any failures and a summary of results

You can also run all tests within a package by running go test ./... from the root of your project.

Learn more about testing in Go in the official documentation.

Analyzing the Implementation File

Now let’s look at our implementation:

// Package todo provides a simple todo list implementation  1. Package comments
package todo                 // 2. Package declaration

// Item represents a todo item with text and completion status  // 3. Struct comments
type Item struct {            // 4. A struct definition
 Text string                  // 5. Field definitions
 Done bool
}

// NewItem creates a new todo item with the specified text // 6. Function comment
func NewItem(text string) Item {  // 7. Function definition.
 return Item{                 // 8. Struct initialization
  Text: text,                 // 9. Fields initialization
  Done: false,
 }
}

Go encourages documentation comments before declarations (1) (3) (6). These comments can be processed by the go doc command to provide help about your package:

$ go doc -all ./internal/todo
package todo // import "github.com/vintharas/go-todo/internal/todo"

Package todo provides a simple todo list implementation

TYPES

type Item struct {
        Text string
        Done bool
}
    Item represents a todo item with text and completion status

func NewItem(text string) Item
    NewItem creates a new todo item with the specified text

And they can be turned into a web-based documentation using the godoc tool. Try the following:

# Install godoc tool
$ go install golang.org/x/tools/cmd/godoc@latest
# Run godoc server
$ godoc -http=:6060
# Navigate to http://localhost:6060/pkg/github.com/vintharas/go-todo/internal/todo/
# to see a web-based documentation for your package.

We use the same package declaration (2) as we did in the test file. This allows the test to access whatever types and functions are defined within the package.

A struct (4) is Go’s way of defining a composite data type. It’s similar to an object or class in JavaScript, but without methods directly attached. A struct is composed of a series of fields (5) of different data types. Notice how the name of the struct is capitalized. This is Go’s way of specifying that a given something is exported and available outside of its package.

Functions in Go are defined with the func keyword (7). This function takes a string parameter and returns a Item. The body of the function shows how we can create a new instance of the Item struct (8) by providing values for each of the struct fields.

Structs vs. JavaScript Objects

It is easier to understand the role of structs if we compare them to JavaScript objects and classes. Go’s structs serve a similar purpose to objects in JavaScript, but with key differences:

  1. Static Typing: Unlike JavaScript objects where you can add properties at runtime, Go structs have a fixed structure defined at compile time.
// JavaScript
const obj = {}
obj.newProperty = 'added at runtime' // Works fine
// Go
item := Item{}
item.NewProperty = "added at runtime" // Compilation error
  1. Methods: In JavaScript, methods are defined directly on objects. In Go, methods are defined separately with a receiver parameter.
// JavaScript
const obj = {
  doSomething() {
    console.log('Doing something')
  },
}
// Go
type MyType struct {
  // fields
}

func (m MyType) DoSomething() {
  fmt.Println("Doing something")
}
  1. Constructor Pattern: JavaScript uses functions or classes with constructor for object creation. Go commonly uses “constructor-like” functions that return initialized structs.
// JavaScript
class Item {
  constructor(text) {
    this.text = text
    this.done = false
  }
}
// Go - our NewItem function follows this pattern
func NewItem(text string) Item {
  return Item{
    Text: text,
    Done: false,
  }
}

This separation of data (structs) and behavior (functions) is a key aspect of Go’s approach to programming, emphasizing simplicity and clarity. It is a key component of Go’s design philosophy that favors composition over inheritance (composing structs with each other instead of having class hierarchies).

Building the Todo List

Let’s expand our functionality to manage a list of todo items. First, we’ll write tests:

Add to todo_test.go:

func TestAddItem(t *testing.T) {
 list := NewList()
 list.Add("Buy milk")

 if len(list.Items) != 1 {
  t.Errorf("Expected 1 item in list, got %d", len(list.Items))
 }

 if list.Items[0].Text != "Buy milk" {
  t.Errorf("Expected text to be 'Buy milk', got '%s'", list.Items[0].Text)
 }
}

func TestCompleteItem(t *testing.T) {
 list := NewList()
 list.Add("Write code")

 err := list.Complete(0)
 if err != nil {
  t.Errorf("Unexpected error: %v", err)
 }

 if !list.Items[0].Done {
  t.Error("Expected item to be marked as done")
 }

 // Test completing an item that doesn't exist
 err = list.Complete(1)
 if err == nil {
  t.Error("Expected error when completing non-existent item")
 }
}

Now, let’s implement the List type in todo.go:

package todo

import (
 "errors"
 "fmt"
)

// Item represents a todo item with text and completion status
type Item struct {
 Text string
 Done bool
}

// NewItem creates a new todo item with the specified text
func NewItem(text string) Item {
 return Item{
  Text: text,
  Done: false,
 }
}

// List represents a collection of todo items
type List struct {
 Items []Item
}

// NewList creates a new, empty todo list
func NewList() *List {
 return &List{
  Items: []Item{},
 }
}

// Add adds a new item to the list
func (l *List) Add(text string) {
 item := NewItem(text)
 l.Items = append(l.Items, item)
}

// Complete marks the item at the specified index as done
func (l *List) Complete(index int) error {
 if index < 0 || index >= len(l.Items) {
  return errors.New("item index out of range")
 }

 l.Items[index].Done = true
 return nil
}

// String returns a formatted string representation of the list
func (l *List) String() string {
 if len(l.Items) == 0 {
  return "No items in the todo list"
 }

 result := "Todo List:\n"
 for i, item := range l.Items {
  status := " "
  if item.Done {
   status = "✓"
  }
  result += fmt.Sprintf("%d. [%s] %s\n", i+1, status, item.Text)
 }

 return result
}

Run the tests to make sure everything passes:

go test ./internal/todo

Understanding Go Collections and Methods

In this section, we’ve introduced several new Go concepts. Let’s explore them in detail.

Slices vs. Arrays in Go

In our List struct, we used a slice of Item structures:

type List struct {
 Items []Item
}

In Go, there are two collection types for storing sequences of elements:

  1. Arrays: Fixed-length sequences with a length that’s part of their type.
var fixedSizeArray [5]int  // An array of 5 integers
  1. Slices: Dynamic-length views into arrays.
var dynamicSlice []int     // A slice of integers (can grow)

While JavaScript only has dynamic arrays, Go distinguishes between fixed-size arrays and dynamic slices. Slices are much more commonly used in Go because of their flexibility:

// JavaScript
const array = []
array.push(1, 2, 3) // Can grow dynamically
// Go
slice := []int{}
slice = append(slice, 1, 2, 3) // Grows dynamically using append

The append Function

In our Add method, we used the append function:

func (l *List) Add(text string) {
 item := NewItem(text)
 l.Items = append(l.Items, item)
}

Unlike JavaScript, where arrays have methods, Go uses functions that operate on data:

// JavaScript
array.push(newItem) // Method call on array object
// Go
slice = append(slice, newItem) // Function call with slice as argument

The append function creates a new slice with the additional elements and returns it. This is why we need to assign the result back to l.Items. If the underlying array has enough capacity, append will use it; otherwise, it will allocate a new, larger array.

The len Function

In the Complete method, we used the len function to check the bounds:

if index < 0 || index >= len(l.Items) {
 return errors.New("item index out of range")
}

Again, Go uses a function rather than a property:

// JavaScript
if (index < 0 || index >= array.length) {
  /* ... */
}
// Go
if index < 0 || index >= len(slice) { /* ... */ }

Methods with Receivers

Go doesn’t have classes, but you can define methods that operate on specific types using receivers:

// Add adds a new item to the list
func (l *List) Add(text string) {
 item := NewItem(text)
 l.Items = append(l.Items, item)
}

The (l *List) part is called a receiver. It specifies that this function is a method on the List type. The * indicates a pointer receiver, meaning we can modify the List.

In JavaScript, methods are properties of objects that contain functions:

// JavaScript
class List {
  add(text) {
    const item = new Item(text)
    this.items.push(item)
  }
}
// Go
type List struct {
  Items []Item
}

func (l *List) Add(text string) {
  item := NewItem(text)
  l.Items = append(l.Items, item)
}

Pointers and References

Notice that our NewList function returns a *List (a pointer to a List), not a List directly:

func NewList() *List {
 return &List{
  Items: []Item{},
 }
}
  • The & operator creates a pointer to a value
  • The * in *List indicates a pointer to a List

What if we returned List instead of *List?

If we changed our function to return a List value instead of a pointer:

func NewList() List {  // No * here
 return List{          // No & here
  Items: []Item{},
 }
}

This would have significant implications:

  1. Copy Semantics: Go would make a copy of the List when returning it and when passing it to functions. Any modifications would affect only the copy, not the original.

  2. Method Receivers: Our methods with pointer receivers like func (l *List) Add(text string) wouldn’t work with value types without a pointer conversion.

  3. Performance: For larger structs, copying the entire value can be less efficient than passing a pointer.

Let’s see this with a simple example:

// With value semantics
func main() {
  list := NewList()         // Returns a List (no pointer)
  modifyList(list)          // Passes a copy of list
  fmt.Println(list.Items)   // Still empty []
}

func modifyList(l List) {   // Takes a copy of List
  l.Items = append(l.Items, Item{Text: "Task", Done: false})
  // Modification only affects the copy
}
// With pointer semantics (what we're using)
func main() {
  list := NewList()         // Returns a *List (pointer)
  modifyList(list)          // Passes the pointer
  fmt.Println(list.Items)   // Contains ["Task"]
}

func modifyList(l *List) {  // Takes a pointer to List
  l.Items = append(l.Items, Item{Text: "Task", Done: false})
  // Modification affects the original
}

This is different from JavaScript, which passes objects by reference implicitly. In Go, you explicitly work with pointers when you want to modify a value passed to a function. This explicitness is part of Go’s philosophy - making it clear when a function might modify its arguments.

for-range Loop

In the String method, we used a for-range loop:

for i, item := range l.Items {
 status := " "
 if item.Done {
  status = "✓"
 }
 result += fmt.Sprintf("%d. [%s] %s\n", i+1, status, item.Text)
}

This is similar to JavaScript’s for...of and array methods with a twist:

// JavaScript equivalent
l.items.forEach((item, i) => {
  const status = item.done ? '✓' : ' '
  result += `${i + 1}. [${status}] ${item.text}\n`
})

The Go for-range loop gives you both the index and value in each iteration. If you don’t need the index, you can use _ to ignore it:

for _, item := range l.Items {
 // use item but not its index
}

Error Handling

Go handles errors by returning them as values, rather than using exceptions:

func (l *List) Complete(index int) error {
 if index < 0 || index >= len(l.Items) {
  return errors.New("item index out of range")
 }

 l.Items[index].Done = true
 return nil
}

This is very different from JavaScript:

// JavaScript
complete(index) {
  if (index < 0 || index >= this.items.length) {
    throw new Error("Item index out of range");
  }
  this.items[index].done = true;
}
// Go
func (l *List) Complete(index int) error {
  if index < 0 || index >= len(l.Items) {
    return errors.New("item index out of range")
  }
  l.Items[index].Done = true
  return nil
}

Go’s approach to error handling has both fans and critics, but it makes error handling explicit and encourages developers to deal with errors when they occur. Error handling and Go is a great article that lays out Go’s error handling philosophy.

Implementing Persistence

A todo app isn’t very useful if it loses all your todos when you close it. Let’s add simple file-based persistence using JSON.

First, the tests in todo_test.go:

func TestSaveAndLoad(t *testing.T) {
 // Create a temporary file for testing
 tmpfile, err := os.CreateTemp("", "todo-test")
 if err != nil {
  t.Fatalf("Could not create temp file: %v", err)
 }
 // Tear down
 defer os.Remove(tmpfile.Name())

 // Create and save a list
 list := NewList()
 list.Add("Task 1")
 list.Add("Task 2")
 list.Complete(0)

 if err := list.Save(tmpfile.Name()); err != nil {
  t.Fatalf("Failed to save list: %v", err)
 }

 // Load the list from the file
 loadedList := NewList()
 if err := loadedList.Load(tmpfile.Name()); err != nil {
  t.Fatalf("Failed to load list: %v", err)
 }

 // Verify the loaded list matches the original
 if len(loadedList.Items) != 2 {
  t.Errorf("Expected 2 items, got %d", len(loadedList.Items))
 }

 if !loadedList.Items[0].Done {
  t.Error("Expected first item to be completed")
 }

 if loadedList.Items[0].Text != "Task 1" {
  t.Errorf("Expected text 'Task 1', got '%s'", loadedList.Items[0].Text)
 }
}

Now, add the persistence functions to todo.go:

import (
 "encoding/json"  // new import for encoding/decoding json
 "errors"
 "fmt"
 "os"             // new import for reading and writing files
)

// Save writes the todo list to a file in JSON format
func (l *List) Save(filename string) error {
 data, err := json.Marshal(l)
 if err != nil {
  return err
 }

 return os.WriteFile(filename, data, 0644)
}

// Load reads a todo list from a file
func (l *List) Load(filename string) error {
 data, err := os.ReadFile(filename)
 if err != nil {
  return err
 }

 return json.Unmarshal(data, l)
}

Don’t forget to add the missing imports at the top of the file.

Understanding Persistence in Go

Let’s pause to understand the key Go concepts we’ve learned in this section.

Setup and Teardown in Go Tests

In traditional testing frameworks like Jest (JavaScript), Mocha, or JUnit, you typically have specialized methods for test setup and teardown:

// JavaScript with Jest
beforeEach(() => {
  // setup code
})

afterEach(() => {
  // teardown code
})

test('something', () => {
  // test code
})

Go takes a simpler, more direct approach. In our test, we used:

func TestSaveAndLoad(t *testing.T) {
 // Create a temporary file for testing
 tmpfile, err := os.CreateTemp("", "todo-test")
 if err != nil {
  t.Fatalf("Could not create temp file: %v", err)
 }
 // Tear down
 defer os.Remove(tmpfile.Name())

 // Test logic...
}

Two key aspects of Go’s approach:

  1. The defer Statement: The defer statement schedules a function call to be executed just before the function returns. This ensures cleanup happens even if the test fails or panics.

  2. Self-Contained Tests: Each test is self-contained with its own setup and teardown. There’s no shared state between tests unless you explicitly create it.

This approach is simple but powerful. It makes each test function fully self-contained and easy to understand without needing to look at setup/teardown code elsewhere. If one still wants to reuse setup and tear down code for a number of tests one can follow this approach.

Working with JSON in Go

Our persistence implementation uses Go’s encoding/json package:

import "encoding/json"

// Serializing to JSON
data, err := json.Marshal(l)

// Deserializing from JSON
err := json.Unmarshal(data, l)

Key points about JSON handling in Go:

  1. Structs and JSON: Go automatically maps struct fields to JSON properties. By default, it uses the capitalized field names.

  2. Struct Field Tags: Go has a unique feature called “struct tags” - metadata attached to struct fields as string literals:

type Item struct {
  Text string `json:"text" validate:"required" db:"item_text"`
  Done bool   `json:"done" default:"false"`
}

Field tags allow us to decorate struct fields with metadata to achieve a variety of purposes:

  • Format: The syntax is a backtick-enclosed (“) string with space-separated key:"value" pairs
  • Usage: Different libraries look for specific keys in these tags
  • Common Tags:
    • json:"name" - Controls how the field appears in JSON (used by the encoding/json package)
    • db:"column" - Maps to database column names (used by database libraries)
    • validate:"rule" - Defines validation rules (used by validation libraries)
    • form:"field" - Maps to form field names (used by web frameworks)

In our todo app, we could use tags to:

  • Make JSON field names lowercase: json:"text" instead of "Text"
  • Omit empty fields: json:"done,omitempty"
  • Skip fields from serialization: json:"-"

For example:

type Item struct {
  Text string `json:"text"`      // Lowercase in JSON
  Done bool   `json:"done"`      // Lowercase in JSON
  Notes string `json:",omitempty"` // Skip if empty
  ID int      `json:"-"`         // Never include in JSON
}
  1. Error Handling: Both Marshal and Unmarshal return errors if something goes wrong, following Go’s explicit error handling pattern.

File I/O in Go

We used Go’s os package for file operations:

import "os"

// Writing to a file
err := os.WriteFile(filename, data, 0644)

// Reading from a file
data, err := os.ReadFile(filename)

Notable aspects:

  1. Simple API: Go provides straightforward functions for common file operations. WriteFile and ReadFile handle opening, writing/reading, and closing files.

  2. File Permissions: The 0644 in WriteFile is a Unix-style file permission (read/write for owner, read-only for others).

  3. Error Handling: Like all Go I/O operations, these functions return errors that you must check.

  4. File Testing: For tests, we used os.CreateTemp("", "todo-test") to create a temporary file that gets automatically cleaned up.

Building the CLI Interface

Let’s build our command-line interface incrementally, starting with a basic structure and adding features one by one. This approach will make it easier to understand how everything fits together.

Step 1: A Simple CLI

First, let’s create a simple version that just loads and displays todos. Create cmd/todo/main.go:

package main

import (
 "fmt"
 "os"

 "github.com/yourusername/todo/internal/todo"
)

const (
 todoFile = "todos.json"
)

func main() {
 // Load existing todos
 todoList := todo.NewList()
 if _, err := os.Stat(todoFile); err == nil {
  if err := todoList.Load(todoFile); err != nil {
   fmt.Fprintln(os.Stderr, "Error loading todos:", err)
   os.Exit(1)
  }
 }

 // For now, just print the todo list
 fmt.Println(todoList)
}

func saveTodos(list *todo.List) {
 if err := list.Save(todoFile); err != nil {
  fmt.Fprintln(os.Stderr, "Error saving todos:", err)
  os.Exit(1)
 }
}

This code sets up our basic structure. It imports the necessary packages, loads any existing todos from a file, and defines a helper function for saving todos.

At this point, our app isn’t very useful — it just loads and prints the todo list. Let’s build and test it:

go build -o todo ./cmd/todo
./todo

You should see “No items in the todo list” if you haven’t created any todos yet.

Step 2: Adding the ‘add’ Command

Now let’s implement the ability to add new todo items by parsing command-line arguments:

package main

import (
 "fmt"
 "os"
 "strings"

 "github.com/yourusername/todo/internal/todo"
)

const (
 todoFile = "todos.json"
)

func main() {
 // Load existing todos
 todoList := todo.NewList()
 if _, err := os.Stat(todoFile); err == nil {
  if err := todoList.Load(todoFile); err != nil {
   fmt.Fprintln(os.Stderr, "Error loading todos:", err)
   os.Exit(1)
  }
 }

 // Process command line arguments
 args := os.Args[1:] // Skip the program name

 if len(args) == 0 {
  // Default action: print the todo list
  fmt.Println(todoList)
  return
 }

 // Get the command
 command := args[0]

 if command == "add" {
  if len(args) < 2 {
   fmt.Println("Error: missing todo text")
   os.Exit(1)
  }

  // Join all remaining arguments as the todo text
  text := strings.Join(args[1:], " ")
  todoList.Add(text)
  saveTodos(todoList)
  fmt.Println("Added:", text)
 } else {
  fmt.Printf("Unknown command: %s\n", command)
  fmt.Println("Available commands: add")
  os.Exit(1)
 }
}

The key additions are:

  1. We parse os.Args[1:] to get the command-line arguments (skipping the program name).
  2. We check if the first argument is “add” and process it accordingly.
  3. We use strings.Join to combine all remaining arguments as the todo text.

Let’s rebuild and test:

go build -o todo ./cmd/todo
./todo add "Learn Go fundamentals"
./todo

Step 3: Adding the ‘list’ Command

Let’s add an explicit “list” command using a switch statement for better command handling:

// Replace the if-else command handling with:
switch command {
case "add":
 if len(args) < 2 {
  fmt.Println("Error: missing todo text")
  os.Exit(1)
 }
 text := strings.Join(args[1:], " ")
 todoList.Add(text)
 saveTodos(todoList)
 fmt.Println("Added:", text)

case "list":
 fmt.Println(todoList)

default:
 fmt.Printf("Unknown command: %s\n", command)
 fmt.Println("Available commands: add, list")
 os.Exit(1)
}

Now users can explicitly list their todos with todo list.

Step 4: Adding the ‘complete’ Command

Let’s add the ability to mark todo items as complete:

import (
 "fmt"
 "os"
 "strconv"  // Add this import
 "strings"

 "github.com/yourusername/todo/internal/todo"
)

// ...

// Add this case to the switch statement
case "complete":
 if len(args) < 2 {
  fmt.Println("Error: missing item number")
  os.Exit(1)
 }

 num, err := strconv.Atoi(args[1])
 if err != nil {
  fmt.Println("Error: invalid item number:", args[1])
  os.Exit(1)
 }

 if err := todoList.Complete(num - 1); err != nil {
  fmt.Fprintln(os.Stderr, "Error completing todo:", err)
  os.Exit(1)
 }

 saveTodos(todoList)
 fmt.Println("Marked item as completed")

Here, we’re using the strconv.Atoi function to convert a string to an integer. This allows users to specify which item to mark as complete.

Notice we’re subtracting 1 from the user-provided number because lists are displayed to users 1-based (starting from 1), but our array is 0-based (starting from 0).

Step 5: Adding Interactive Mode

Finally, let’s add an interactive mode that allows users to manage their todos through a prompt:

import (
 "bufio"    // Add this import
 "flag"     // Add this import
 "fmt"
 "os"
 "strconv"
 "strings"

 "github.com/yourusername/todo/internal/todo"
)

func main() {
 // Define flags
 interactiveFlag := flag.Bool("i", false, "Run in interactive mode")

 // Parse flags but keep access to non-flag arguments
 flag.Parse()
 args := flag.Args()

 // Load existing todos
 todoList := todo.NewList()
 if _, err := os.Stat(todoFile); err == nil {
  if err := todoList.Load(todoFile); err != nil {
   fmt.Fprintln(os.Stderr, "Error loading todos:", err)
   os.Exit(1)
  }
 }

 // Handle interactive mode flag
 if *interactiveFlag {
  runInteractive(todoList)
  return
 }

 // Handle subcommands (existing code)
 // ...
}

func runInteractive(list *todo.List) {
 scanner := bufio.NewScanner(os.Stdin)

 for {
  fmt.Println("\n" + list.String())
  fmt.Println("\nCommands:")
  fmt.Println("  add <text>    - Add a new todo")
  fmt.Println("  complete <n>  - Mark item n as completed")
  fmt.Println("  quit          - Exit the program")
  fmt.Print("\n> ")

  if !scanner.Scan() {
   break
  }

  input := scanner.Text()
  parts := strings.SplitN(input, " ", 2)
  cmd := parts[0]

  switch cmd {
  case "add":
   if len(parts) < 2 {
    fmt.Println("Error: missing todo text")
    continue
   }
   list.Add(parts[1])
   saveTodos(list)
   fmt.Println("Added:", parts[1])

  case "complete":
   if len(parts) < 2 {
    fmt.Println("Error: missing item number")
    continue
   }
   num, err := strconv.Atoi(parts[1])
   if err != nil {
    fmt.Println("Error: invalid item number")
    continue
   }
   if err := list.Complete(num - 1); err != nil {
    fmt.Println("Error:", err)
    continue
   }
   saveTodos(list)
   fmt.Println("Marked item as completed")

  case "quit", "exit":
   return

  default:
   fmt.Println("Unknown command:", cmd)
  }
 }
}

The interactive mode uses the bufio.Scanner to read user input line by line. We present a menu of commands, read the user’s choice, and execute the corresponding action.

Step 6: Adding Help Functionality

A well-designed CLI should provide help information to guide users. Let’s implement both a help command and a -h flag:

import (
 "bufio"
 "flag"
 "fmt"
 "os"
 "strconv"
 "strings"

 "github.com/yourusername/todo/internal/todo"
)

func main() {
 // Define flags
 interactiveFlag := flag.Bool("i", false, "Run in interactive mode")
 helpFlag := flag.Bool("h", false, "Show help information")

 // Parse flags but keep access to non-flag arguments
 flag.Parse()
 args := flag.Args()

 // Show help if the -h flag is provided
 if *helpFlag {
  printHelp()
  return
 }

 // Load existing todos
 todoList := todo.NewList()
 if _, err := os.Stat(todoFile); err == nil {
  if err := todoList.Load(todoFile); err != nil {
   fmt.Fprintln(os.Stderr, "Error loading todos:", err)
   os.Exit(1)
  }
 }

 // Handle interactive mode flag
 if *interactiveFlag {
  runInteractive(todoList)
  return
 }

 // Handle subcommands
 if len(args) == 0 {
  // Default action when no command is provided
  fmt.Println(todoList)
  return
 }

 // Get the subcommand
 command := args[0]

 switch command {
 case "add":
  // ... existing add command code ...

 case "list":
  fmt.Println(todoList)

 case "complete":
  // ... existing complete command code ...

 case "help":
  printHelp()

 default:
  fmt.Printf("Unknown command: %s\n", command)
  fmt.Println("Run 'todo help' or 'todo -h' for usage information")
  os.Exit(1)
 }
}

func printHelp() {
 helpText := `
Todo - A simple command line todo manager

Usage:
  todo [command] [arguments]
  todo [flags]

Commands:
  add <text>     Add a new todo item
  list           List all todo items
  complete <n>   Mark item n as completed
  help           Show this help message

Flags:
  -h             Show this help message
  -i             Run in interactive mode

Examples:
  todo add "Learn Go testing"
  todo list
  todo complete 2
  todo -i
`
 fmt.Println(helpText)
}

With these changes, users can get help by typing either todo help or todo -h. We’ve also improved the error message for unknown commands to guide users toward the help system.

Also, let’s update the interactive mode to include help:

func runInteractive(list *todo.List) {
 // ... existing code ...

 switch cmd {
 // ... existing cases ...

 case "help":
  fmt.Println("\nAvailable commands:")
  fmt.Println("  add <text>    - Add a new todo")
  fmt.Println("  list          - List all todos")
  fmt.Println("  complete <n>  - Mark item n as completed")
  fmt.Println("  help          - Show this help message")
  fmt.Println("  quit          - Exit the program")

 // ... other cases ...
 }
}

This makes our CLI much more user-friendly, especially for first-time users who aren’t sure what commands are available.

Understanding Go’s Command-Line Packages

Go provides three main ways to work with command-line interfaces:

  1. os.Args: A simple slice of strings containing all command-line arguments, with the program name at index 0.

  2. flag Package: A more sophisticated package for parsing command-line flags with various types (bool, string, int, etc.) and automatic help generation.

  3. Subcommand Pattern: What we’ve implemented manually - a command followed by its specific arguments (like git commit or docker run).

These approaches can be combined, as we’ve done here — using the flag package for flags like -i and -h, while manually parsing positional arguments for commands like add and complete.

For simple CLIs like ours, this approach works well. For more complex applications, third-party packages like cobra or urfave/cli provide more advanced command-line functionality, including automatic help generation, command aliasing, and nested subcommands.

Building and Running the App

Now we can build and run our todo app:

go build -o todo ./cmd/todo

This creates an executable called todo. To make it available system-wide, you have several options:

Option 1: Move the binary to your PATH

# On macOS/Linux
mv todo /usr/local/bin/

# On Windows, you might add the executable to a directory in your PATH

Option 2: Using go install

The go install command is a more elegant way to install Go applications. It builds and installs the binary directly into your Go bin directory (which should be in your PATH):

# From your project directory
go install ./cmd/todo

# Or from anywhere, if you've pushed your code to GitHub
go install github.com/vintharas/go-todo/cmd/todo@latest

The @latest tag tells Go to use the latest version. You can also specify a specific version or commit hash.

Using go install has several advantages:

  • It handles the PATH for you (as long as $GOPATH/bin or $HOME/go/bin is in your PATH)
  • It makes it easy for others to install your tool
  • It’s the standard way to distribute Go command-line tools

Make sure your Go bin directory is in your PATH:

# Check if Go bin is in your PATH
echo $PATH | grep -q "$(go env GOPATH)/bin" && echo "Already in PATH" || echo "Not in PATH"

# Add it to your PATH if needed (add this to your .bashrc, .zshrc, etc.)
export PATH="$PATH:$(go env GOPATH)/bin"

Now you can run it from anywhere:

# Add a todo
todo add "Learn more Go"

# List all todos
todo list

# Mark a todo as completed
todo complete 1

# Show help information
todo help
# or
todo -h

# Run in interactive mode
todo -i

The command-based interface makes our CLI more intuitive and follows the conventions of popular command-line tools like Git and Docker.

What Makes Go Special?

Now that we’ve built a functional todo app, let’s explore what makes Go unique and powerful:

Mental Models for Understanding Go

  1. Simplicity by Design: Go was designed to be simple and easy to learn. It has a small set of language features and avoids complex abstractions.

  2. Static Typing with Inference: Go gives you the safety of static typing without excessive verbosity, thanks to type inference.

  3. Composition over Inheritance: Go uses interfaces and composition instead of inheritance, leading to more flexible and maintainable code.

  4. Value Semantics: Go emphasizes passing values rather than references, which can make reasoning about code easier. By default, everything in Go is passed by value (meaning it’s copied), including structs. When you want reference behavior, you explicitly use pointers, making it clear when a function might modify its arguments. This differs significantly from JavaScript, where objects are always passed by reference.

  5. Concurrency as a First-Class Concept: Go’s goroutines and channels make concurrent programming safer and more accessible.

  6. Pragmatic Error Handling: Go uses explicit error checking rather than exceptions, which can lead to more robust code.

Go vs. JavaScript

Let’s compare Go with JavaScript to highlight some key differences:

Feature Go JavaScript
Type System Static, strong Dynamic, weak (TypeScript brings static typing)
Compilation Compiled to native code Interpreted or JIT compiled
Concurrency Built-in with goroutines & channels Asynchronous with callbacks, promises, async/await
Error Handling Explicit with multiple return values Exception-based
Memory Management Garbage collected Garbage collected
Standard Library Rich and consistent Minimal (relies on npm ecosystem)
Tooling Built-in formatting, testing, profiling Requires external tools (ESLint, Jest, etc.)
Performance Generally faster Generally slower
Ecosystem Smaller but high-quality Vast but varying quality

Understanding Go’s Composition over Inheritance

Go takes a different approach to code reuse than many object-oriented languages. Where JavaScript uses class inheritance, Go uses composition and interfaces:

Inheritance in JavaScript

// JavaScript inheritance
class Animal {
  constructor(name) {
    this.name = name
  }

  speak() {
    console.log(`${this.name} makes a noise.`)
  }
}

class Dog extends Animal {
  speak() {
    console.log(`${this.name} barks!`)
  }
}

const dog = new Dog('Rex')
dog.speak() // Rex barks!

JavaScript follows a classical inheritance model where Dog is an Animal and inherits its properties and methods.

Composition in Go

Go doesn’t have inheritance. Instead, it uses several composition techniques:

  1. Embedding structs:
// Go composition via embedding
type Animal struct {
    Name string
}

func (a Animal) Speak() {
    fmt.Printf("%s makes a noise.\n", a.Name)
}

type Dog struct {
    Animal               // Embedding Animal struct
    BreedName string
}

func (d Dog) Speak() {
    fmt.Printf("%s barks!\n", d.Name)
}

func main() {
    dog := Dog{Animal: Animal{Name: "Rex"}}
    dog.Speak()          // Rex barks!
    dog.Animal.Speak()   // Rex makes a noise.
}
  1. Interface composition:
// Interface composition
type Speaker interface {
    Speak()
}

type Eater interface {
    Eat(food string)
}

// Combine interfaces through composition
type Animal interface {
    Speaker
    Eater
}
  1. Has-a relationships using struct fields:
type Engine struct {
    Horsepower int
}

func (e Engine) Start() {
    fmt.Println("Engine started")
}

type Car struct {
    Engine Engine    // Has-a relationship
    Model string
}

func main() {
    car := Car{
        Engine: Engine{Horsepower: 300},
        Model: "Sedan",
    }
    car.Engine.Start()
}

In our todo app, we used composition by keeping our data structures simple and combining them as needed. For example, a List has Items rather than inheriting from some shared collection type.

Understanding Go’s Concurrency Model

Go’s approach to concurrency is one of its most distinctive features, especially when compared to JavaScript:

JavaScript Concurrency

JavaScript uses an event loop with callbacks, promises, and async/await:

// JavaScript asynchronous programming
function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Data loaded'), 2000)
  })
}

async function main() {
  console.log('Starting...')
  const result = await fetchData()
  console.log(result)
  console.log('Done!')
}

main()
// Logs:
// Starting...
// (2 seconds later)
// Data loaded
// Done!

JavaScript’s model is inherently single-threaded with non-blocking I/O. When multiple things need to happen at once, you use asynchronous callbacks or promises that execute when an operation completes.

Go Concurrency

Go uses goroutines (lightweight threads) and channels for communication:

// Go concurrency with goroutines and channels
func fetchData(ch chan string) {
    time.Sleep(2 * time.Second)
    ch <- "Data loaded"  // Send data to channel
}

func main() {
    fmt.Println("Starting...")

    ch := make(chan string)
    go fetchData(ch)     // Start a goroutine

    result := <-ch       // Receive from channel (blocks until data arrives)
    fmt.Println(result)
    fmt.Println("Done!")
}
// Prints:
// Starting...
// (2 seconds later)
// Data loaded
// Done!

Key differences:

  1. Goroutines vs. Async/Await:

    • Goroutines are managed by the Go runtime and can run in parallel across multiple CPU cores
    • JavaScript’s async/await still executes on a single thread (with exceptions for Web Workers)
  2. Channels vs. Promises:

    • Go channels provide synchronization and allow data to be passed between goroutines
    • Promises represent a future value and don’t directly facilitate two-way communication
  3. Scalability:

    • Go can run thousands or even millions of goroutines concurrently
    • JavaScript is limited by its single-threaded nature, even with async operations

While we didn’t use concurrency in our todo app, it’s one of Go’s strengths for building high-performance network services, web servers, and distributed systems.

Understanding Go’s Value Semantics

One of Go’s distinctive features is its approach to value semantics. Let’s explore this concept more deeply as it’s a key difference from JavaScript:

In Go, everything is passed by value by default - meaning a copy is made:

func modifyValue(num int) {
    num = 10                // Modifies the copy, not the original
}

func main() {
    x := 5
    modifyValue(x)
    fmt.Println(x)          // Still prints 5, not 10
}

This applies to structs too:

func modifyItem(item Item) {
    item.Text = "Modified"  // Only modifies the copy
}

func main() {
    item := NewItem("Original")
    modifyItem(item)
    fmt.Println(item.Text)  // Still prints "Original"
}

When you want to modify the original value, you explicitly use pointers:

func modifyItem(item *Item) {
    item.Text = "Modified"  // Modifies the original
}

func main() {
    item := NewItem("Original")
    modifyItem(&item)       // Pass a pointer using &
    fmt.Println(item.Text)  // Now prints "Modified"
}

In our todo app, this is why our NewList function returns a pointer (*List) and our Add and Complete methods use pointer receivers (func (l *List)). This explicit approach makes it clear when a function might modify its arguments.

This differs significantly from JavaScript, where objects are always passed by reference:

// JavaScript
function modifyObject(obj) {
  obj.property = 'Modified' // Modifies the original
}

const object = { property: 'Original' }
modifyObject(object)
console.log(object.property) // Prints "Modified"

Go’s approach has several advantages:

  • Makes code more predictable and easier to reason about
  • Helps prevent unintended side effects
  • Provides performance optimizations for small values
  • Makes concurrency safer by encouraging data isolation

Extending the Todo App

Now that you understand the basics of Go, consider extending the todo app with these features:

  1. Add due dates to todo items
  2. Implement categories or tags for todo items
  3. Add the ability to delete todos
  4. Implement searching and filtering
  5. Add priority levels
  6. Create recurring todos

Conclusion

In this tutorial, we’ve learned Go by building a practical command-line todo application. We’ve seen how Go’s simplicity, strong typing, and excellent standard library make it easy to build robust and efficient applications.

Go’s approach to software development emphasizes clarity, simplicity, and practicality. By focusing on these principles, Go enables developers to build software that is both maintainable and performant.

Whether you’re building microservices, CLIs, or backend systems, Go offers a compelling combination of developer productivity and runtime efficiency that makes it worth considering for your next project.

Now that you have the basics down, continue exploring Go’s rich ecosystem and powerful features to build even more sophisticated applications!

More Resources

Official Resources

Books

Interactive Learning

CLI Development

  • Cobra - A library for creating powerful modern CLI applications
  • Viper - Complete configuration solution (pairs well with Cobra)
  • urfave/cli - A simple, fast, and fun package for building command line apps

Blogs and Tutorials

Community

Advanced Topics


Jaime González García

Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.Jaime González García