LearnAI ToolsCareerPractice BuildsPlayContact
SwiftBeginner~1.5 hours

Console Task Manager

Build a command-line to-do app using structs and arrays to track tasks and their status.

StructsCollectionsControl Flow

Overview

A console task manager is the natural first project for seeing why Swift reaches for a `struct`, not a `class`, by default. A `Task` here is a small bundle of data — an id, a title, and a completion flag — that should behave like a value: when it is stored in an array, copied into a local constant, or passed into a function, each copy should be fully independent, with no risk of one part of the program silently changing a task that another part is still holding onto. That is exactly the guarantee Swift's struct value semantics give you for free, and it is why idiomatic Swift code reaches for `struct` unless there is a specific reason a type needs shared, reference-based identity.

By the end of this tutorial you will have a console application built around two structs: `Task`, which models one to-do item, and `TaskManager`, which owns an `[Task]` array and exposes add, list, toggle, and delete operations over it. Because both are structs, every method on `TaskManager` that changes its own `tasks` array has to be explicitly marked `mutating` — a small piece of syntax that makes Swift's value semantics visible right in the method signature, instead of leaving "does this method change my data?" as a question you have to answer by reading the whole implementation.

What You'll Build
  • A `Task` struct with `id`, `title`, and `isComplete` fields.
  • A `TaskManager` struct wrapping an `[Task]` array with add, list, toggle, and delete operations.
  • Automatic id assignment so two tasks can never collide.
  • A `pendingCount()` helper computed with `filter`, so it can never drift out of sync with the real list.
  • A `readLine()`-driven menu loop dispatched with a `switch` statement.

Prerequisites

  • Structs — defining a `struct` with stored properties, and the difference between `let` and `var` properties.
  • Arrays — Swift's `[Element]` collection type, `append`, and iterating with `for-in`.
  • Control flow — `if`/`else`, `switch`, and `while` loops.
  • Optionals basics — recognizing that `readLine()` returns `String?` and `Int(...)` returns `Int?`.
  • Mutating methods — why a struct's own methods must be marked `mutating` before they can change its stored properties.

Project Structure

The whole program fits in a single file, `main.swift`, since Swift executable targets run top-level statements in that file directly as the program's entry point — there is no explicit `main()` function required, unlike Java or C. `Task` is a plain data struct with no logic of its own beyond describing what one to-do item looks like. `TaskManager` is the layer above it: it owns the `[Task]` array, hands out ids, and exposes one method per menu action. The top-level code at the bottom of the file never touches the array directly — it only ever calls methods on a single `var manager` instance, which keeps every rule about how tasks are created or removed enforced in exactly one place.

Step 1: Define the Task Struct

Every field on `Task` is either `let` or `var` depending on whether it should ever change after the task is created. `id` is `let` because an id assigned once should never be reassigned; `title` and `isComplete` are `var` because renaming a task and marking it done are both real features this project needs to support.

// A single to-do item. Declared as a struct, not a class, because a Task is
// a simple value — whenever it is copied (passed into a function, stored in
// an array, etc.) each copy should be fully independent. That is exactly the
// value semantics Swift gives every struct for free, with no shared-reference
// surprises later when the same task is edited from two different places.
struct Task {
let id: Int // Assigned once by TaskManager; `let` because an id must never change after creation
var title: String // The task's description; `var` because renaming a task is a real feature
var isComplete: Bool // Whether the task has been finished; flipped by the "toggle complete" menu action
}

Step 2: Build the TaskManager and Add Tasks

`TaskManager` is where the collection actually lives, declared as a `struct` for the same value-semantics reasons as `Task`. Because it is a struct, `addTask(title:)` has to be marked `mutating` — without that keyword, Swift refuses to compile any method that assigns to `self`'s own stored properties, since an ordinary (non-mutating) method on a struct is not allowed to change the value it belongs to.

// Owns every Task in memory and is the only type allowed to create, list, or
// remove them. TaskManager is a struct too — it only ever mutates its own
// `tasks` array, never anything external, so the top-level code below just
// needs a single `var manager` to hold the current state.
struct TaskManager {
private var tasks: [Task] = [] // Backing array; grows automatically as tasks are appended
private var nextId = 1 // Next id to hand out, incremented after every successful add
// `mutating` is required: this method writes to `tasks` and `nextId`, and
// TaskManager is a struct — without `mutating`, Swift rejects any method
// that assigns to `self`'s stored properties.
mutating func addTask(title: String) {
let task = Task(id: nextId, title: title, isComplete: false) // isComplete always starts false
tasks.append(task)
nextId += 1 // Guarantees every future task gets a fresh, unused id
}
}

Step 3: List Tasks and Count What's Pending

`listTasks()` is a plain (non-mutating) method, since it only reads `tasks` and never changes it. `pendingCount()` uses `filter`, which builds a brand-new array containing only the elements the closure returns `true` for, leaving `tasks` itself untouched — the pending count is computed fresh every time it is called, so it can never drift out of sync with the real list the way a separately stored counter could.

func listTasks() {
if tasks.isEmpty {
print("No tasks yet.")
return
}
for task in tasks { // for-in walks the array in insertion order
let mark = task.isComplete ? "[x]" : "[ ]" // Ternary is idiomatic here for a two-way choice
print("\(mark) #\(task.id): \(task.title)")
}
}
// filter runs the closure over every element and keeps only the ones where it
// returns true — "every task that is NOT complete," expressed in one line
// instead of a manual loop with an accumulator variable.
func pendingCount() -> Int {
tasks.filter { !$0.isComplete }.count
}
Example Usage

Click Run to see what this code prints.

Step 4: Toggle Task Completion

`firstIndex(where:)` returns `Int?`, an Optional, because no element in `tasks` may actually match the id the caller passed in — `guard let` unwraps that Optional and, if it is `nil`, exits the method immediately with `return false`. `guard let` is preferred over `if let` here specifically because the "not found" branch is the exceptional case that should bail out early, leaving the rest of the method free to assume `index` is a valid, unwrapped `Int`.

// `mutating` again: this flips a field on one element inside `tasks`.
mutating func toggleComplete(id: Int) -> Bool {
// firstIndex(where:) returns Int? — an Optional Int, because no task in
// the array may actually match the id the caller passed in.
guard let index = tasks.firstIndex(where: { $0.id == id }) else {
return false // guard let's else branch exits early when nothing was found
}
tasks[index].isComplete.toggle() // toggle() flips a Bool in place; clearer intent than isComplete = !isComplete
return true
}

Step 5: Delete a Task

`removeAll(where:)` removes every element matching the predicate in a single pass, which is the cleanest way to express "delete the task whose id equals this one" without manually tracking a loop index while mutating the array being iterated. Comparing `tasks.count` before and after tells the caller whether anything was actually removed, without `Task` needing an `Equatable` conformance just for this one check.

mutating func deleteTask(id: Int) -> Bool {
let originalCount = tasks.count
tasks.removeAll { $0.id == id } // removeAll(where:) removes every matching element in a single pass
return tasks.count != originalCount // If the count shrank, a task was actually removed
}

Step 6: Build the Menu Loop

The top-level code creates one `var manager` shared by every menu action and loops with `while running` until the user picks Exit. `readLine()` returns `String?` and `Int(...)` on a string also returns `Int?`, so `guard let choiceLine = readLine(), let choice = Int(choiceLine)` chains two Optional unwraps in a single statement — if either the line can't be read or it isn't a valid number, the `else` branch prints a message and `continue`s straight to the next loop iteration instead of crashing.

var manager = TaskManager() // `var`, not `let` — every mutating method below needs a mutable owner
var running = true
while running {
print("""
===== CONSOLE TASK MANAGER =====
1. Add Task
2. List Tasks
3. Toggle Complete
4. Delete Task
5. Exit
""")
print("Enter your choice: ", terminator: "") // terminator: "" keeps the prompt on the same line as input
guard let choiceLine = readLine(), let choice = Int(choiceLine) else {
print("Invalid input, try again.")
continue
}
switch choice {
case 1:
print("Enter task title: ", terminator: "")
let title = readLine() ?? "" // Nil-coalescing: an unreadable line becomes an empty string, not a crash
if title.isEmpty {
print("Title cannot be empty.")
} else {
manager.addTask(title: title)
print("Task added.")
}
case 2:
manager.listTasks()
print("\(manager.pendingCount()) task(s) pending.")
case 3:
print("Enter task id to toggle: ", terminator: "")
if let idLine = readLine(), let id = Int(idLine) {
print(manager.toggleComplete(id: id) ? "Task updated." : "No task with that id.")
} else {
print("Invalid id.")
}
case 4:
print("Enter task id to delete: ", terminator: "")
if let idLine = readLine(), let id = Int(idLine) {
print(manager.deleteTask(id: id) ? "Task deleted." : "No task with that id.")
} else {
print("Invalid id.")
}
case 5:
print("Goodbye!")
running = false // Ends the while loop's condition on the next check
default:
print("Invalid choice, try again.") // Catches anything outside 1-5
}
}

Complete Code

Here is the full program assembled in the correct order, ready to save as `main.swift` and run with `swift main.swift`, or paste into a Swift Playground.

struct Task {
let id: Int
var title: String
var isComplete: Bool
}
struct TaskManager {
private var tasks: [Task] = []
private var nextId = 1
mutating func addTask(title: String) {
let task = Task(id: nextId, title: title, isComplete: false)
tasks.append(task)
nextId += 1
}
func listTasks() {
if tasks.isEmpty {
print("No tasks yet.")
return
}
for task in tasks {
let mark = task.isComplete ? "[x]" : "[ ]"
print("\(mark) #\(task.id): \(task.title)")
}
}
func pendingCount() -> Int {
tasks.filter { !$0.isComplete }.count
}
mutating func toggleComplete(id: Int) -> Bool {
guard let index = tasks.firstIndex(where: { $0.id == id }) else {
return false
}
tasks[index].isComplete.toggle()
return true
}
mutating func deleteTask(id: Int) -> Bool {
let originalCount = tasks.count
tasks.removeAll { $0.id == id }
return tasks.count != originalCount
}
}
var manager = TaskManager()
var running = true
while running {
print("""
===== CONSOLE TASK MANAGER =====
1. Add Task
2. List Tasks
3. Toggle Complete
4. Delete Task
5. Exit
""")
print("Enter your choice: ", terminator: "")
guard let choiceLine = readLine(), let choice = Int(choiceLine) else {
print("Invalid input, try again.")
continue
}
switch choice {
case 1:
print("Enter task title: ", terminator: "")
let title = readLine() ?? ""
if title.isEmpty {
print("Title cannot be empty.")
} else {
manager.addTask(title: title)
print("Task added.")
}
case 2:
manager.listTasks()
print("\(manager.pendingCount()) task(s) pending.")
case 3:
print("Enter task id to toggle: ", terminator: "")
if let idLine = readLine(), let id = Int(idLine) {
print(manager.toggleComplete(id: id) ? "Task updated." : "No task with that id.")
} else {
print("Invalid id.")
}
case 4:
print("Enter task id to delete: ", terminator: "")
if let idLine = readLine(), let id = Int(idLine) {
print(manager.deleteTask(id: id) ? "Task deleted." : "No task with that id.")
} else {
print("Invalid id.")
}
case 5:
print("Goodbye!")
running = false
default:
print("Invalid choice, try again.")
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `dueDate: Date?` field to `Task` and sort `listTasks()` so the nearest due date shows first.
  • Introduce a `Priority` enum (`.low`, `.medium`, `.high`) and let the list be filtered or sorted by it.
  • Persist tasks to a JSON file with `Codable` and `JSONEncoder`/`JSONDecoder` so the list survives between runs.
  • Add a `searchTasks(containing:)` method using `filter` and `String.contains(_:)` for case-insensitive title search.
  • Track a `completedAt: Date?` timestamp, set the moment a task is toggled complete.

Summary

You built a working task manager where `Task` and `TaskManager` are both structs, giving every copy of a task predictable, independent value semantics instead of shared mutable state. The `mutating` keyword you used on every method that changed `TaskManager`'s own array is not boilerplate — it is Swift making a promise visible in the method signature itself: "this call changes the thing you hold." That same add/list/toggle/delete shape, built on structs and arrays, is the foundation you will reuse in almost every data-driven Swift console app from here on.