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 `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}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 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 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.