Skip to content

05 — Swift & AppKit for TS/FP Devs

Goal: a translation layer. Every Swift/AppKit idiom you’ll hit in this app, mapped to TypeScript / FP concepts you already own. Read it once end-to-end, then keep it open as a reference while reading source. Examples are drawn from this codebase, not toy code.

Use this as a lookup table, not a tutorial. Grouped by how often you’ll trip on each.


The single most important mental shift. Swift enum is not the C/TS numeric enum — it’s a proper sum type (discriminated union), often with attached methods.

// Simple — like a string union, but a real type:
enum VisualMode: Equatable { case normal; case desaturated }
// With raw values (string-backed) — the wire contract:
enum ActivityState: String { case idle = "idle"; /* … */ }
// With ASSOCIATED VALUES — a tagged union carrying payloads:
enum StateReadError: Error, Equatable {
case fileNotFound
case schemaNewer(got: Int, expected: Int) // carries data
}

🇹🇸 Maps to:

type VisualMode = "normal" | "desaturated"
type StateReadError =
| { tag: "fileNotFound" }
| { tag: "schemaNewer"; got: number; expected: number }

🗣️ In plain English. Swift lets you define “this value is exactly one of these N things, sometimes carrying extra data” — and then the compiler refuses to build until every one of the N is handled. It’s the language feature this whole app leans on hardest.

Exhaustive switch with no default is the payoff: the compiler forces you to handle every case (your never-check, automatic). You’ll see this everywhere — e.g. the loop’s decide switches over Result cases exhaustively. When you add an enum case, the compiler walks you to every switch that needs updating. This is a feature you’ll rely on heavily in v2’s schema work.

Result<Success, Failure> is itself just a built-in enum:

enum Result<Success, Failure: Error> { case success(Success); case failure(Failure) }

🇹🇸 It’s Either<Failure, Success> / your { ok: true, value } | { ok: false, error }.


2. Optional is T | undefined, but enforced

Section titled “2. Optional is T | undefined, but enforced”

String? means “a String or nothing.” It’s literally enum Optional<T> { case none; case some(T) } with sugar. You cannot use the value without unwrapping — the compiler won’t let you pretend it’s there.

let origin: String? = snapshot.sourceEvent?.origin // optional chaining, like TS ?.
if let origin = origin { use(origin) } // unwrap if present
guard let origin = origin else { return } // unwrap-or-bail (early return)
let safe = origin ?? "manual" // default, like TS ??

🇹🇸 You already know ?. and ?? — same meaning. The new one is guard let … else { return }: an early-return unwrap. Read it as “I need this to exist; if it doesn’t, leave now.” It keeps the happy path un-nested below.

🗣️ In plain English. “Maybe there’s a value, maybe there isn’t” is a first-class concept, and the compiler forces you to say what happens in the “isn’t” case before you may touch the value. Whole classes of undefined-is-not-a-function bugs simply can’t compile.

⚠️ Double optional String?? appears in StateJsonReader (lastActivityAt: String??). That distinguishes “key absent” (outer .none) from “key present but JSON null” (.some(.none)). It collapses to a single nil at the snapshot boundary. Don’t panic when you see ?? nil — it’s coercing String??String?.


3. struct vs class — value vs reference (this one bites)

Section titled “3. struct vs class — value vs reference (this one bites)”
struct (value type)class (reference type)
Copied on assignment/passyes — independent copyno — shared reference
Identitynone (compared by value)has identity (===)
In this appdata: StateSnapshot, Outcome, RowSpec, FloatingAppStatebehavior/lifecycle: every …Controller, …Panel, …Driver, …Renderer
struct StateSnapshot { let activityState: ActivityState; /* … */ } // immutable record
final class LivePollingDriver { /* holds mutable state, has identity */ }

🇹🇸 TS analogy. A struct behaves like an immutable plain object you always spread-copy ({...obj}); pass it around freely, nobody can mutate your copy. A class behaves like a TS class instance — shared by reference, has identity.

The rule of thumb this codebase follows (and you should too): data is a struct (value, immutable, let fields); things with a lifecycle and mutable internal state are a final class. final = “not subclassable” — the default choice here; inheritance is barely used. 🇹🇸 Think final class ≈ a regular TS class you don’t intend anyone to extends.

⚠️ The bite: if you pass a struct into a function and mutate it, you’re mutating a copy — the caller’s value is unchanged. Coming from JS objects (always by reference) this is the opposite default. Most structs here are let anyway, so it rarely surprises, but know it.

🗣️ In plain English. Swift has two kinds of “thing”: copies (hand someone a photocopy — they can scribble on theirs, yours is untouched) and shared originals (everyone holds the same document). Data in this app travels as photocopies; long-lived machinery is a shared original. The bite for JS folks: JS hands out shared originals by default, Swift hands out photocopies.


4. protocol — interfaces, used for testing seams

Section titled “4. protocol — interfaces, used for testing seams”

A protocol is an interface. The app uses them mainly to inject test doubles.

@MainActor
protocol FloatingPetPanelManaging: AnyObject {
func show(frame: CGRect)
func apply(state: ActivityState, visualMode: VisualMode)
// …
}

FloatingPetController depends on FloatingPetPanelManaging (the protocol), not the concrete FloatingPetPanelController — so a test can pass a fake panel and assert it received the right calls.

🇹🇸 TS analogy. An interface, and the pattern is classic dependency inversion: depend on the interface, inject the implementation. You do this with constructor-injected interfaces in TS already.

extension ProtocolName { … } providing method bodies = default implementations (like a default method on a TS interface via a mixin). You’ll see extension FloatingPetPanelManaging give empty default bodies so conformers only override what they care about.

AnyObject constraint = “only classes can conform” (needed for weak references; see below).


5. Closures + typealias — functions as values & dependency injection

Section titled “5. Closures + typealias — functions as values & dependency injection”

This codebase injects functions, not just objects. A typealias names a function type.

typealias Apply = (ActivityState, VisualMode) -> Void // = type Apply = (s, m) => void
typealias Reader = (String) -> Result<StateSnapshot, StateReadError>
init(reader: @escaping Reader = StateJsonReader.read(at:), …) { }

🗣️ In plain English. LivePollingDriver doesn’t hardcode “read from disk” — it takes a function that returns a Result. Production passes the real reader; tests pass { _ in .success(fakeSnapshot) }. Pure functional DI — exactly your instinct.

  • @escaping = “this closure is stored and called later” (outlives the call). 🇹🇸 Just means the callback is retained, not called synchronously. You don’t need to think hard about it beyond “stored callbacks are @escaping.”
  • { [weak self] in … } capture lists — see memory section below.
  • Default parameter values (= StateJsonReader.read(at:)) — like TS default args.

6. Memory: weak self, retain cycles (the AppKit tax)

Section titled “6. Memory: weak self, retain cycles (the AppKit tax)”

Swift uses ARC (automatic reference counting), not a tracing GC. Two objects that strongly reference each other never get freed — a retain cycle (leak). The fix peppered throughout this app:

panel.setFrameChangeHandler { [weak self] frame in
self?.persistFrameChange(frame)
}

🗣️ In plain English. “Inside this stored callback, don’t keep self alive; if self is already gone, do nothing (self?.).” Without [weak self], the panel holds the closure, the closure holds the controller, the controller holds the panel → cycle → leak.

🇹🇸 TS analogy. You almost never think about this in JS because the GC handles cycles. Here it’s manual. Rule of thumb when reading: [weak self] in a stored closure is just leak-avoidance boilerplate — mentally skip it; it doesn’t change the logic. When writing a stored callback that captures self, add [weak self] by default.

Related: the many var someController: X? properties held on MenubarApp “strongly so it is not deallocated” — ARC frees anything not strongly held, so the app explicitly keeps long-lived objects alive by storing them. 🇹🇸 In JS you’d just have a module-level variable; here holding the reference is what keeps it from being collected.

🗣️ In plain English. Swift cleans up memory by counting who’s still holding each object. Two objects gripping each other never get released — so callbacks say “hold this loosely” (weak), and important long-lived objects must be gripped somewhere or they silently evaporate. Most of the odd bracketed boilerplate you’ll see is exactly this: grip management, not logic.


7. @MainActor — the UI thread, enforced by the compiler

Section titled “7. @MainActor — the UI thread, enforced by the compiler”
@MainActor final class LivePollingDriver { }

@MainActor = “all of this must run on the main (UI) thread,” checked at compile time. AppKit UI must be touched on the main thread; this annotation makes that a type error to violate rather than a runtime crash.

🇹🇸 TS analogy. JS is single-threaded so you never think about this. Closest parallel: imagine the compiler enforcing “this code only runs inside the browser’s main event loop, never in a Worker.” You’ll see Task { @MainActor in … } to hop back onto the main actor from a timer callback — that’s “schedule this work on the UI thread.”

Mostly you just leave these annotations alone. Know that they’re why you don’t see manual DispatchQueue.main.async everywhere.

🗣️ In plain English. Screen-drawing on a Mac must happen on one special thread. The @MainActor label makes breaking that rule a compile error instead of a mysterious crash — the compiler is the bouncer.


  • Argument labels: frames(for state: ActivityState) is called frames(for: .idle). The for is an external label, state the internal name. 🇹🇸 Like named params, but positional and part of the method’s identity. read(at:) and apply(state:visualMode:) are full method names.
  • let vs var = const vs let. let is immutable; prefer it (the code does).
  • static members = class-level, like TS static. static let production = … is a shared constant.
  • No ;, no parens around if/switch conditions.
  • Trailing closure: Timer.scheduledTimer(…) { … } — the last closure arg moves outside the parens. 🇹🇸 Cosmetic; like passing an arrow fn as the last arg.
  • guard … else { return } — covered above; the dominant control-flow idiom. Read it as an assertion that bails.
  • MARK: - comments are just section headers (the editor shows them in the jump bar). Navigational, ignore.

TypeWhat it isTS/web analogy
NSStatusItemthe menu-bar icon slota system tray item
NSPanel / NSWindowa floating windowa borderless always-on-top BrowserWindow
NSViewa rectangle that draws itselfa DOM element / React component
NSImage / CGImagea bitmap (high-level / low-level)HTMLImageElement / ImageBitmap
SKScene (SpriteKit)a 2D animation/game surface<canvas> + rAF loop
Timerrepeating/one-shot callbacksetInterval / setTimeout
NotificationCenterapp-wide pub/sub event busan EventEmitter / window.addEventListener
Codableauto JSON encode/decode from a type’s shapezod infer + JSON.parse, but compiler-generated
Bundlethe app package; source of bundled assetsyour public/ / packaged resources
CGRect / CGPoint / CGSizegeometry value structs{x,y,w,h} plain objects

Codable deserves a note: conforming a struct to Codable makes the compiler generate JSON encode/decode for free, matching field names (with keyDecodingStrategy = .convertFromSnakeCase to map activity_stateactivityState). 🇹🇸 It’s like getting zod parsing auto-derived from your type — except you can override init(from:) for custom logic (which ActivityState does for the unknown→idle fallback).


Swift tests (XCTest) live in apps/menubar/Tests/MenubarTests/. They look like:

func testGateElevatesHookState() {
driver.tickForTesting() // run one tick synchronously, no real Timer
XCTAssertEqual(captured.last?.state, .greenTdd)
}

(tickForTesting() is real; the exact assertion shape varies per test file.)

🇹🇸 TS analogy. func testX()it("x", …); XCTAssertEqual(a, b)expect(a).toEqual(b). The …ForTesting / tickForTesting() methods you saw are deliberate test seams — public hooks to drive internal logic synchronously without real timers or AppKit. Run them with bun run mac:test (see project release notes) or xcodebuild … test.


How to read an unfamiliar file in this app

Section titled “How to read an unfamiliar file in this app”

A repeatable procedure:

  1. Top doc-comment (///) — this codebase documents why heavily. Read it.
  2. Type declarationsgrep or skim for class/struct/enum. A “big” file is usually several small types; find the one you need.
  3. init(...) — what does it depend on? Injected functions/protocols tell you its collaborators.
  4. Public methods — the surface others call.
  5. Ignore on first pass: [weak self], @MainActor, MARK:, @objc, deinit cleanup. They’re machinery, not logic.

➡️ Next: 06 — Where v2 is going.