import React, { useState, useEffect, useRef, useMemo, memo, useCallback } from "react"; // ============================================================================= // Splint type catalog (mirrors the actual library: Resource, Catalog, Lens, // Job, Selection, Setting, Credential). // ============================================================================= const TYPES = { Resource: { tone: "slate", blurb: "Decoded remote data — a book, a channel, a user." }, Catalog: { tone: "indigo", blurb: "Ordered collection of Resources, loaded by criteria." }, Lens: { tone: "teal", blurb: "Filter / sort / group over a Catalog. Owns no data." }, Job: { tone: "amber", blurb: "An async operation and the Phase it's currently in." }, Selection: { tone: "rose", blurb: "Which item id is selected." }, Setting: { tone: "emerald", blurb: "A typed user preference, UserDefaults-backed." }, Credential: { tone: "violet", blurb: "A keychain-backed secret." }, }; const TONE = { slate: { chip: "bg-slate-100 text-slate-700 border-slate-200", dot: "bg-slate-500", soft: "bg-slate-50", border: "border-slate-200" }, indigo: { chip: "bg-indigo-100 text-indigo-700 border-indigo-200", dot: "bg-indigo-500", soft: "bg-indigo-50", border: "border-indigo-200" }, teal: { chip: "bg-teal-100 text-teal-700 border-teal-200", dot: "bg-teal-500", soft: "bg-teal-50", border: "border-teal-200" }, amber: { chip: "bg-amber-100 text-amber-800 border-amber-300", dot: "bg-amber-600", soft: "bg-amber-50", border: "border-amber-300" }, rose: { chip: "bg-rose-100 text-rose-700 border-rose-200", dot: "bg-rose-500", soft: "bg-rose-50", border: "border-rose-200" }, emerald: { chip: "bg-emerald-100 text-emerald-800 border-emerald-200", dot: "bg-emerald-500", soft: "bg-emerald-50", border: "border-emerald-200" }, violet: { chip: "bg-violet-100 text-violet-700 border-violet-200", dot: "bg-violet-500", soft: "bg-violet-50", border: "border-violet-200" }, }; // ============================================================================= // Data for the exhibits // ============================================================================= // Exhibit 1 — properties of an @Observable class for a "Book Reader" screen, // each one secretly belongs to a Splint shape. const SCREEN_PROPERTIES = [ { decl: "var books: [Book]", type: "Catalog", why: "A loaded collection of decoded resources." }, { decl: "var isLoadingBooks: Bool", type: "Job", why: "Part of an async operation's lifecycle — belongs inside a Phase." }, { decl: "var loadBooksError: Error?", type: "Job", why: "Also a facet of the async lifecycle. Collapses into Phase.failed." }, { decl: "var searchQuery: String", type: "Lens", why: "Filter criteria applied over the Catalog." }, { decl: "var selectedGenre: Genre?", type: "Lens", why: "A filter over the Catalog. Derived view, not data." }, { decl: "var sortOrder: SortOrder", type: "Lens", why: "A sort over the Catalog. Also a Lens knob." }, { decl: "var selectedBookID: Book.ID?", type: "Selection", why: "Just an id. It is not a book." }, { decl: "var currentUser: User?", type: "Resource", why: "Decoded remote data. One value, not a collection." }, { decl: "var fontSize: Double", type: "Setting", why: "User preference — belongs in UserDefaults, auto-observed." }, { decl: "var preferredTheme: Theme", type: "Setting", why: "Another preference. Same shape." }, { decl: "var apiToken: String?", type: "Credential", why: "A secret. Belongs in the Keychain, not your view model." }, ]; // Exhibit 2 — short list of row items. User clicks a row to bump its name. const INITIAL_ROWS = [ { id: 1, name: "Swift" }, { id: 2, name: "SwiftUI" }, { id: 3, name: "Observation" }, { id: 4, name: "Actors" }, { id: 5, name: "Macros" }, { id: 6, name: "Concurrency" }, { id: 7, name: "Package" }, { id: 8, name: "Testing" }, ]; // Exhibit 3 — catalog of books for the Lens demo. const BOOKS = [ { id: 1, title: "Mrs. Dalloway", author: "Virginia Woolf", year: 1925, genre: "Fiction" }, { id: 2, title: "Beloved", author: "Toni Morrison", year: 1987, genre: "Fiction" }, { id: 3, title: "The Road", author: "Cormac McCarthy", year: 2006, genre: "Fiction" }, { id: 4, title: "A Visit from the Goon Squad", author: "Jennifer Egan", year: 2010, genre: "Fiction" }, { id: 5, title: "The Overstory", author: "Richard Powers", year: 2018, genre: "Fiction" }, { id: 6, title: "On Writing", author: "Stephen King", year: 2000, genre: "Nonfiction" }, { id: 7, title: "The Sixth Extinction", author: "Elizabeth Kolbert", year: 2014, genre: "Nonfiction" }, { id: 8, title: "Just Kids", author: "Patti Smith", year: 2010, genre: "Nonfiction" }, { id: 9, title: "A Brief History of Time", author: "Stephen Hawking", year: 1988, genre: "Nonfiction" }, { id: 10, title: "Being and Time", author: "Martin Heidegger", year: 1927, genre: "Nonfiction" }, { id: 11, title: "Howl", author: "Allen Ginsberg", year: 1956, genre: "Poetry" }, { id: 12, title: "Ariel", author: "Sylvia Plath", year: 1965, genre: "Poetry" }, { id: 13, title: "Citizen", author: "Claudia Rankine", year: 2014, genre: "Poetry" }, { id: 14, title: "The Waste Land", author: "T. S. Eliot", year: 1922, genre: "Poetry" }, { id: 15, title: "Devotions", author: "Mary Oliver", year: 2017, genre: "Poetry" }, { id: 16, title: "The Mythical Man-Month", author: "Frederick P. Brooks", year: 1975, genre: "Computing" }, { id: 17, title: "Structure and Interpretation of Programs", author: "Abelson & Sussman", year: 1985, genre: "Computing" }, { id: 18, title: "The Pragmatic Programmer", author: "Hunt & Thomas", year: 1999, genre: "Computing" }, { id: 19, title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", year: 2017, genre: "Computing" }, { id: 20, title: "A Philosophy of Software Design", author: "John Ousterhout", year: 2018, genre: "Computing" }, ]; const GENRES = ["All", "Fiction", "Nonfiction", "Poetry", "Computing"]; // ============================================================================= // Shared leaf components // ============================================================================= function TypeChip({ type, size = "sm" }) { const tone = TYPES[type]?.tone ?? "slate"; const cls = TONE[tone]; const pad = size === "xs" ? "text-[10px] px-1.5 py-[1px]" : "text-[11px] px-2 py-[2px]"; return ( {type} ); } function Knob({ label, children }) { return ( ); } function Rule() { return
; } // ============================================================================= // Row used by Exhibit 2. Defined at module scope so `memo` is stable. // Increments a render-counter ref during render and flashes each time its // render function actually runs. // ============================================================================= function RawRenderRow({ item, onBump }) { const rc = useRef(0); rc.current += 1; const count = rc.current; return (
); } const MemoRenderRow = memo(RawRenderRow, (prev, next) => prev.item === next.item && prev.onBump === next.onBump); // ============================================================================= // Global styles (fonts + flash keyframes) // ============================================================================= function GlobalStyles() { return ( ); } // ============================================================================= // Hero // ============================================================================= function Hero() { return (
A Splint field guide · four exhibits

Why bother naming the shapes
of your data?

Splint says every SwiftUI screen you'll ever write is assembled from the same small set of data shapes — resources, catalogs, lenses, jobs, selections, settings, credentials. Naming them sounds cosmetic. It isn’t.

Four exhibits, below. Twist the knobs until the cost of not naming becomes visible, then go see what shape your next screen is in.

{Object.keys(TYPES).map(t => )}
); } // ============================================================================= // Exhibit chrome // ============================================================================= function ExhibitShell({ number, kicker, title, thesis, children }) { return (
EXHIBIT {String(number).padStart(2, "0")} {kicker}

{title}

{thesis}

{children}
); } function Insight({ children }) { return ( ); } // ============================================================================= // Exhibit 1 — The unnamed @Observable class // ============================================================================= function Exhibit1() { const [revealed, setRevealed] = useState(() => new Set()); const [grouped, setGrouped] = useState(false); const toggle = (idx) => { setRevealed(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }; const revealAll = () => setRevealed(new Set(SCREEN_PROPERTIES.map((_, i) => i))); const hideAll = () => { setRevealed(new Set()); setGrouped(false); }; const progress = revealed.size; const total = SCREEN_PROPERTIES.length; const groups = useMemo(() => { const byType = {}; SCREEN_PROPERTIES.forEach((p, idx) => { if (!byType[p.type]) byType[p.type] = []; byType[p.type].push({ ...p, idx }); }); return byType; }, []); return ( Here’s the state a typical SwiftUI screen accumulates. On the surface it’s just a pile of var declarations. Underneath, every one is secretly a Splint shape. Click to reveal. Then toggle Group by shape to see what the pile wanted to be. } >
@Observable class BookReaderScreen {"{"}
named: {progress} / {total}
{!grouped && (
    {SCREEN_PROPERTIES.map((p, idx) => { const isOn = revealed.has(idx); const tone = TYPES[p.type].tone; return (
  • {isOn && (
    {p.why}
    )}
  • ); })}
)} {grouped && (
{Object.keys(TYPES).filter(t => groups[t]).map(t => (
{TYPES[t].blurb}
    {groups[t].map(p => (
  • {p.decl}
  • ))}
))}
)}
{"}"}
Eleven var declarations. Seven shapes. Three of them (the loading bool, the error, the result array) were pieces of the same Job. Three more (search, genre, sort) were knobs on the same Lens. Naming doesn’t just decorate — it reveals which properties were secretly one thing all along.
); } // ============================================================================= // Exhibit 2 — Observation scope // ============================================================================= function Exhibit2() { const [rows, setRows] = useState(INITIAL_ROWS); const [tickKey, setTickKey] = useState(0); // bumped to force a parent re-render with no data change const [epoch, setEpoch] = useState(0); // bumped to remount the rows and zero the counters const bump = useCallback((id) => { setRows(prev => prev.map(r => r.id === id ? { ...r, name: r.name.endsWith("′") ? r.name.slice(0, -1) : r.name + "′" } : r) ); }, []); const touchUnrelated = () => { setTickKey(k => k + 1); }; const reset = () => { setRows(INITIAL_ROWS); setTickKey(0); setEpoch(e => e + 1); }; return ( SwiftUI’s @Observable{" "} tracks at the property level, but the boundary where tracking happens is the view. Read a property inside a {" "}ForEach closure and the whole parent re-evaluates. Extract the row into its own view and only the changed row wakes up. Same data, different cost. } >
{/* Wide / naive */} ForEach reads item.name inline} >
{rows.map(item => ( ))}
{/* Narrow / correct */} Row is a memoized child component} >
{rows.map(item => ( ))}
Try: | You can also click any row directly to bump it.
Click change row #4 a few times. On the left, every row’s render count ticks — a wide-scope ForEach {" "}wakes all the things. On the right, only row #4. Now click parent re-renders: the wide column flashes 8 times for a non-change; the narrow column doesn’t flinch. This is why Splint keeps lifecycle (phase) separate from result (value) — a header observing only .phase stays asleep while a list bound to .value updates.
); } function Panel({ tone, label, sublabel, children }) { const t = TONE[tone]; return (
{label}
{sublabel}
{children}
); } // ============================================================================= // Exhibit 3 — Lens over Catalog // ============================================================================= function Exhibit3() { const [query, setQuery] = useState(""); const [genre, setGenre] = useState("All"); const [sort, setSort] = useState("title-asc"); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); let out = BOOKS.filter(b => { if (genre !== "All" && b.genre !== genre) return false; if (!q) return true; return b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q); }); const comparators = { "title-asc": (a, b) => a.title.localeCompare(b.title), "title-desc": (a, b) => b.title.localeCompare(a.title), "year-asc": (a, b) => a.year - b.year, "year-desc": (a, b) => b.year - a.year, "author": (a, b) => a.author.localeCompare(b.author), }; return [...out].sort(comparators[sort]); }, [query, genre, sort]); const matchSet = useMemo(() => new Set(filtered.map(b => b.id)), [filtered]); const resetLens = () => { setQuery(""); setGenre("All"); setSort("title-asc"); }; return ( A Catalog is the loaded data. A Lens is just a projection over it — a filter plus a sort. Twist the knobs below. Watch the right column reorder and shrink. Watch the left column — the Catalog itself — stay exactly where it is. } >
{/* Catalog */}
    {BOOKS.map(b => { const matched = matchSet.has(b.id); return (
  • {b.title} {b.year}
    {b.author} · {b.genre.toLowerCase()}
  • ); })}
{/* Arrow connecting them */}
projects through →
{/* Lens */}
    {filtered.length === 0 && (
  • The lens is empty. (The catalog still isn’t.)
  • )} {filtered.map(b => (
  • {b.title} {b.year}
    {b.author} · {b.genre.toLowerCase()}
  • ))}
{/* Lens knobs */}
knobs that mutate the projection, not the data
setQuery(e.target.value)} placeholder="e.g. Morrison, Ariel, Kleppmann" className="mono text-[13px] px-3 py-2 rounded border border-stone-300 bg-stone-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-teal-300 focus:border-teal-400 transition" />
{GENRES.map(g => { const active = genre === g; return ( ); })}
The Catalog sits on the left like a table of contents that doesn’t move. You filter and sort by changing the Lens, not by rebuilding the source. That sounds obvious — until you remember how many SwiftUI codebases carry around a filteredBooks array kept in sync by hand. Duplicate arrays are a whole class of bugs Lens exists to prevent.
); } function Header({ tone, title, countLabel }) { const t = TONE[tone]; return (
{title}
{countLabel}
); } // ============================================================================= // Exhibit 4 — Booleans vs. Phase // ============================================================================= const PHASE_CASES = [ { id: "idle", label: ".idle", blurb: "no work has started" }, { id: "running", label: ".running", blurb: "work is in flight" }, { id: "completed", label: ".completed", blurb: "finished successfully" }, { id: "failed", label: ".failed(msg)", blurb: "failed — message in the associated value" }, ]; function Exhibit4() { // Booleans (left panel — the nonsense bag) const [isLoading, setLoading] = useState(false); const [hasError, setHasError] = useState(false); const [hasData, setHasData] = useState(false); const [isRetrying, setRetrying] = useState(false); // Phase (right panel — the enum) const [phase, setPhase] = useState("idle"); const bools = { isLoading, hasError, hasData, isRetrying }; const rawFlags = Object.entries(bools).filter(([_, v]) => v).map(([k]) => k); // Classify the current boolean combination. const diagnosis = useMemo(() => { const on = rawFlags; const s = on.join("+") || "∅"; if (on.length === 0) return { kind: "valid", name: "idle", msg: "no work started", view: "idle" }; if (s === "isLoading") return { kind: "valid", name: "loading", msg: "loading, no data yet", view: "loading" }; if (s === "isLoading+isRetrying") return { kind: "valid", name: "retrying", msg: "refetching after a prior failure", view: "loading" }; if (s === "hasData") return { kind: "valid", name: "loaded", msg: "data ready, no active fetch", view: "loaded" }; if (s === "hasError") return { kind: "valid", name: "failed", msg: "error, no data", view: "failed" }; // Nonsense combinations: const reasons = []; if (isLoading && hasError) reasons.push("loading yet already errored"); if (isLoading && hasData && !isRetrying) reasons.push("loading without being a retry yet already has data"); if (hasError && hasData) reasons.push("errored but also has data"); if (isRetrying && !isLoading) reasons.push("retrying without loading"); if (isRetrying && hasError) reasons.push("retrying and in failure at once"); if (reasons.length === 0) reasons.push("unreachable state"); return { kind: "bogus", name: "impossible", msg: reasons.join("; "), view: "bogus" }; }, [isLoading, hasError, hasData, isRetrying, rawFlags.join("|")]); // Actions that synchronize both panels when you pretend to do "real" work. const runAction = (name) => { if (name === "start") { setLoading(true); setHasError(false); setRetrying(false); setPhase("running"); } if (name === "succeed") { setLoading(false); setHasError(false); setRetrying(false); setHasData(true); setPhase("completed"); } if (name === "fail") { setLoading(false); setHasError(true); setRetrying(false); setHasData(false); setPhase("failed"); } if (name === "retry") { setLoading(true); setHasError(false); setRetrying(true); setPhase("running"); } if (name === "reset") { setLoading(false); setHasError(false); setRetrying(false); setHasData(false); setPhase("idle"); } }; return ( A Phase has exactly four cases:{" "} .idle,{" "} .running,{" "} .completed,{" "} .failed(msg). Before Splint you probably had the same states, scattered across three or four booleans. Tick the boxes on the left and see what you were secretly asking SwiftUI to render. } >
{/* Booleans panel */}
{[ ["isLoading", isLoading, setLoading], ["hasError", hasError, setHasError], ["hasData", hasData, setHasData], ["isRetrying", isRetrying, setRetrying], ].map(([label, val, set]) => ( ))}
what this renders
{/* Phase panel */}
{PHASE_CASES.map(c => { const active = phase === c.id; return ( ); })}
what this renders
{/* Actions: synchronize both panels */}
Scripted actions: {[ ["start", "amber"], ["succeed", "emerald"], ["fail", "rose"], ["retry", "amber"], ["reset", "slate"], ].map(([n, tone]) => ( ))} Each action sets both models. The enum never contradicts itself.
{/* State-space grid */} The boolean bag carries sixteen combinations. Five of them are the four states you’d have written anyway (idle, loading, retrying, loaded, failed). Eleven are impossible states that you nevertheless have to decide how to render — and, worse, that arbitrary code paths can accidentally construct. A Phase enum collapses the state space to exactly what the domain allows. Less surface, less nonsense.
); } function RenderedView({ kind, diagnosis }) { const base = "rounded border px-3 py-3 min-h-[64px] flex items-center gap-3"; if (kind === "idle") return
idle (nothing yet)
; if (kind === "loading") return
{diagnosis.name === "retrying" ? "Retrying…" : "Loading…"}
; if (kind === "loaded") return
loaded 12 books ready
; if (kind === "failed") return
failed “Network unreachable”
; // bogus return (
impossible state ({diagnosis.msg})
Now you’re writing view code to handle nonsense that shouldn’t exist.
); } function PhaseView({ phase }) { const base = "rounded border px-3 py-3 min-h-[64px] flex items-center gap-3"; if (phase === "idle") return
idle (nothing yet)
; if (phase === "running") return
Loading…
; if (phase === "completed") return
completed 12 books ready
; // failed return
failed “Network unreachable”
; } function Spinner() { return ( ); } function StateSpace({ bools }) { // Enumerate all 16 combinations. Classify each as valid / impossible. const combos = []; for (let i = 0; i < 16; i++) { const b = { isLoading: !!(i & 1), hasError: !!(i & 2), hasData: !!(i & 4), isRetrying: !!(i & 8), }; const sig = `${+b.isLoading}${+b.hasError}${+b.hasData}${+b.isRetrying}`; const valid = sig === "0000" || // idle sig === "1000" || // loading sig === "1001" || // retrying sig === "0010" || // loaded sig === "0100"; // failed combos.push({ ...b, valid, sig }); } const currentSig = `${+bools.isLoading}${+bools.hasError}${+bools.hasData}${+bools.isRetrying}`; return (
state space · all 2⁴ combinations
5 valid · 11 impossible
{combos.map((c, i) => { const isCurrent = c.sig === currentSig; const base = "aspect-square rounded flex items-center justify-center text-[9px] mono font-semibold transition"; const tone = c.valid ? "bg-emerald-100 text-emerald-800 border border-emerald-200" : "bg-rose-50 text-rose-500 border border-rose-200"; const current = isCurrent ? " ring-2 ring-amber-500 ring-offset-1 ring-offset-white scale-110" : ""; return (
{c.valid ? "✓" : "×"}
); })}
A Phase enum makes only the green cells addressable.
); } function LegendSwatch({ label, className, mark }) { return (
{mark} {label}
); } // ============================================================================= // Closing // ============================================================================= function Closing() { return (

The whole thesis, tightened.

Splint doesn’t invent new abstractions. It names the ones your app was already full of. Catalogs and lenses and jobs and settings are in every SwiftUI app that has ever been written — they just used to hide in the properties of view models, under names nobody agreed on.

The exhibits above are each a different way to feel the same point:

  • Naming reveals which properties were secretly one thing. (Exhibit 1.)
  • Naming draws observation boundaries in the right places. (Exhibit 2.)
  • Naming distinguishes data from the view over data. (Exhibit 3.)
  • Naming collapses impossible states. (Exhibit 4.)

If your next screen has four booleans, a filtered array, and an apiToken: String? — it’s trying to tell you its shape. Splint is what you spell that shape in.

github.com/searlsco/splint · swift-testing · strict concurrency · macOS 26.2 + · MIT
); } // ============================================================================= // Root // ============================================================================= export default function App() { return ( <>
); }