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.
- A
Taskstruct withid,title, andisCompletefields. - A
TaskManagerstruct wrapping an[Task]array with add, list, toggle, and delete operations. - Automatic id assignment so two tasks can never collide.
- A
pendingCount()helper computed withfilter, so it can never drift out of sync with the real list. - A
readLine()-driven menu loop dispatched with aswitchstatement.
Prerequisites
- Structs — defining a
structwith stored properties, and the difference betweenletandvarproperties. - Arrays — Swift's
[Element]collection type,append, and iterating withfor-in. - Control flow —
if/else,switch, andwhileloops. - Optionals basics — recognizing that
readLine()returnsString?andInt(...)returnsInt?. - Mutating methods — why a struct's own methods must be marked
mutatingbefore 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}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 continues straight to the next loop iteration instead of crashing.
var manager = TaskManager() // `var`, not `let` — every mutating method below needs a mutable ownervar 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
Click Run to see what this code prints.
Extend This Project
- Add a
dueDate: Date?field toTaskand sortlistTasks()so the nearest due date shows first. - Introduce a
Priorityenum (.low,.medium,.high) and let the list be filtered or sorted by it. - Persist tasks to a JSON file with
CodableandJSONEncoder/JSONDecoderso the list survives between runs. - Add a
searchTasks(containing:)method usingfilterandString.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.