Skip to content

02 — Swift for TypeScript Devs

Goal: read any Swift file in the companion app without stopping to Google syntax. This is not “all of Swift” — it’s the ~20 concepts this project actually uses, each mapped to TypeScript. 🗣️ plain-English recaps close each section.


Swift’s most important cultural difference from TS: structs are the default, classes are the exception.

struct DailySummary: Codable, Equatable { // value type — copied on assignment
let date: Date
let totalSeconds: Int
}
final class SyncEngine { // reference type — shared identity
var lastSync: Date?
}
// TS: everything object-shaped is a reference; you fake value semantics
// with readonly + spread copies.
interface DailySummary { readonly date: string; readonly totalSeconds: number }

Structs are copied on assignment and can’t be mutated through a shared reference — which is most of what you use Object.freeze, immer, or spread copies to fake in TS. Rule of thumb for this app: models are structs, long-lived machinery (network client, sync engine) are classes.

🗣️ In plain English. Swift data is like numbers in a spreadsheet — copying a cell gives you your own copy. Only a few “machine” objects are shared the way all JavaScript objects are.


var goal: DailyGoal? = nil // like: let goal: DailyGoal | undefined
let minutes = goal?.minutes ?? 240 // optional chaining + nil-coalescing — same as TS!
if let goal { // "if let" unwrap — no TS equivalent
print(goal.minutes) // inside this block, goal is non-optional
}
guard let goal else { return } // early-exit unwrap — like a type-narrowing
print(goal.minutes) // guard: rest of scope has non-optional goal

?. and ?? are literally the same operators as TS. The new moves are if let / guard let, which narrow like a TS if (goal === undefined) return — but enforced by the compiler, with no !-style escape hatch you’d be tempted to abuse (Swift has ! force-unwrap; treat it like TS as any).

🗣️ In plain English. Swift makes “this might be missing” a first-class type and forces you to check before using it. You already do this in strict TypeScript; Swift just never lets you cheat.


Enums with payloads = discriminated unions

Section titled “Enums with payloads = discriminated unions”

TS discriminated unions are Swift enums with associated values — and this app uses them everywhere (loading states, burnout verdicts, sync results):

enum LoadState<Value> {
case idle
case loading
case loaded(Value)
case failed(Error)
}
switch state {
case .idle, .loading: ProgressView()
case .loaded(let summaries): SummaryList(summaries)
case .failed(let error): ErrorBanner(error)
}
type LoadState<V> =
| { kind: 'idle' } | { kind: 'loading' }
| { kind: 'loaded'; value: V } | { kind: 'failed'; error: Error }

switch must be exhaustive — add a case, and every switch that misses it becomes a compile error. That’s the never-trick exhaustiveness check from TS, built in.

🗣️ In plain English. Swift lets a value be “one of several labeled shapes,” and the compiler guarantees you handled every shape. Same idea as TypeScript’s tagged unions, minus the ceremony.


Protocols = interfaces (+ conformance you’ll actually use)

Section titled “Protocols = interfaces (+ conformance you’ll actually use)”
protocol StatsProvider {
func summaries(from: Date, to: Date) async throws -> [DailySummary]
}
struct SupabaseStatsProvider: StatsProvider { } // real one
struct FixtureStatsProvider: StatsProvider { } // previews & tests

Exactly interface StatsProvider { … } with two implementations — this is how the app swaps the live Supabase client for canned fixtures in SwiftUI previews and unit tests.

Three conformances appear on nearly every model in this project:

ConformanceMeaningTS analogy
CodableJSON encode/decodeJSON.parse + zod schema, autogenerated
Equatable== compares by valuedeep-equal, compiler-written
Identifiablehas stable id for listsReact key= prop requirement

🗣️ In plain English. A protocol is a checklist of abilities. Anything that ticks every box can be used wherever the checklist is required — that’s how we swap real network code for fake data when testing.


Closures, map/filter/reduce, and trailing syntax

Section titled “Closures, map/filter/reduce, and trailing syntax”

Functional-programming muscle memory transfers directly:

let insideWindow = durations
.filter { $0.startedAt >= windowStart && $0.startedAt < windowEnd }
.map(\.seconds)
.reduce(0, +)

$0 is the implicit first argument (like a super-terse arrow function); \.seconds is a key path, roughly d => d.seconds. Trailing-closure syntax — the { … } after the call — matters because all of SwiftUI is built on it (next chapter).

🗣️ In plain English. Swift’s list-processing looks and behaves like JavaScript’s filter/map/reduce, with slightly terser shorthand.


Concurrency: async/await you know, actors you don’t

Section titled “Concurrency: async/await you know, actors you don’t”
func refresh() async throws -> [DailySummary] {
let rows = try await client.fetch("summaries", since: lastSync) // await — same mental model
return try rows.map { try JSONDecoder().decode(DailySummary.self, from: $0) }
}

async/await reads identically to TS. Two additions:

  1. throws is in the signature. Errors are typed control flow, not anything-goes rejected promises. try await marks both effects at the call site. Think of it as every promise being Promise<Result<T, Error>> and the compiler forcing you to unwrap.
  2. Actors & @MainActor. JS is single-threaded; Swift is not. UI must be touched on the main thread, so UI-facing classes are annotated @MainActor — the compiler then proves you never update UI from a background thread. Where TS has “there is only one thread, relax,” Swift has “there are many threads, and the compiler is watching.”
@MainActor @Observable
final class DashboardModel { // safe to bind to SwiftUI
var state: LoadState<[DailySummary]> = .idle
}

🗣️ In plain English. Waiting for slow things looks exactly like modern JavaScript. The new part: iPhones do many things at once, so Swift makes you label which code touches the screen, and checks the labels at compile time.


do {
let invoice = try InvoiceBuilder.build(project: p, range: r, rate: 120)
try pdf.write(to: url)
} catch let error as InvoiceError {
banner = .invoiceFailed(error) // typed catch — like instanceof checks, but exhaustive
} catch {
banner = .unknown(error)
}

Result<Success, Failure> is the same Either you’d hand-roll in FP-style TS, and converts to/from throwing functions freely.

🗣️ In plain English. Errors are labeled envelopes, not surprise explosions. Functions that can fail say so in their signature, and callers must open the envelope.


Property wrappers & macros — the @ things

Section titled “Property wrappers & macros — the @ things”

You’ll see @State, @Observable, @Environment, @Query, @Model. They are compiler-expanded annotations — closer to Svelte’s $state runes or decorators-with-codegen than to plain TS decorators. Full tour next chapter; for now: the @ prefix means “this declaration gets superpowers via code generation.”

🗣️ In plain English. Words starting with @ are magic labels that make the compiler write plumbing code for you — mostly “redraw the screen when this changes.”


TypeScriptSwift
interface / structural typeprotocol / struct (nominal!)
T | undefinedT?
discriminated unionenum with associated values
JSON.parse + zodCodable + JSONDecoder
Promise<T>async -> T (+ throws)
readonly / constlet (deep immutability for structs)
Array.prototype.map/filter/reducesame names, $0 shorthand, key paths
npm packageSwift Package (SPM)
tsconfig.json strictnesson by default, no config, no escape
Svelte $state / MobX@Observable

⚠️ The one habit to unlearn: TS types are structural (shape matches ⇒ compatible); Swift types are nominal (name matches ⇒ compatible). Two identical structs with different names are unrelated types. Where you’d casually pass object literals around in TS, Swift wants you to name the type and construct it explicitly.


In Xcode: File → New → Playground, then port this TS one-liner to Swift using filter, key paths, and reduce:

const focusHours = days.filter(d => !d.isRestDay).reduce((a, d) => a + d.seconds, 0) / 3600
Answer
let focusHours = Double(
days.filter { !$0.isRestDay }.map(\.seconds).reduce(0, +)
) / 3600