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.
Values vs references: struct vs class
Section titled “Values vs references: struct vs class”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.
Optionals: T? is T | undefined with teeth
Section titled “Optionals: T? is T | undefined with teeth”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-narrowingprint(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 onestruct FixtureStatsProvider: StatsProvider { … } // previews & testsExactly 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:
| Conformance | Meaning | TS analogy |
|---|---|---|
Codable | JSON encode/decode | JSON.parse + zod schema, autogenerated |
Equatable | == compares by value | deep-equal, compiler-written |
Identifiable | has stable id for lists | React 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:
throwsis in the signature. Errors are typed control flow, not anything-goes rejected promises.try awaitmarks both effects at the call site. Think of it as every promise beingPromise<Result<T, Error>>and the compiler forcing you to unwrap.- 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 @Observablefinal 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.
Error handling: do/try/catch and Result
Section titled “Error handling: do/try/catch and Result”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.”
Cheat-sheet: TS → Swift
Section titled “Cheat-sheet: TS → Swift”| TypeScript | Swift |
|---|---|
interface / structural type | protocol / struct (nominal!) |
T | undefined | T? |
| discriminated union | enum with associated values |
JSON.parse + zod | Codable + JSONDecoder |
Promise<T> | async -> T (+ throws) |
readonly / const | let (deep immutability for structs) |
Array.prototype.map/filter/reduce | same names, $0 shorthand, key paths |
| npm package | Swift Package (SPM) |
tsconfig.json strictness | on 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.
Prove it to yourself
Section titled “Prove it to yourself”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) / 3600Answer
let focusHours = Double( days.filter { !$0.isRestDay }.map(\.seconds).reduce(0, +)) / 3600