List, row, and detail: Named, typealiases, and extensions

April 11, 2026

When every row and header takes the full domain model, previews get noisy and diffs get wide. You do not need a second “list DTO” struct for every screen. You can keep navigation and editing on the real types and still share one row layout.

This post is a small pattern in three parts: a Named contract for what the list actually shows, typealias for repeated protocol stacks, and extension YourType: Named so classes and structs opt in without new stored properties.

A tiny protocol and two aliases

List UI only needs a title line and an optional subtitle. Everything else—Codable, ObservableObject, persistence—can stay on the model.

protocol Named {
    var name: String { get }
    var details: String? { get }
}

typealias NamedObject = Identifiable & Named & ObservableObject
typealias NamedValue = Identifiable & Named & Hashable

NamedObject and NamedValue are compile-time constraints, not new runtime types. Pick NamedObject for reference models you observe, and NamedValue for value types you pass by ID in navigation.

One generic row

struct NamedRow<Model: Named>: View {
    let model: Model

    var body: some View {
        VStack(alignment: .leading) {
            Text(model.name)
            if let details = model.details {
                Text(details)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Any type that conforms to Named can use NamedRow with no further generics at the call site.

Reference types: conform in an extension

Say you already have a document class with Identifiable and @Published fields. You do not rename title to name for the UI layer—you map in an extension.

final class Document: ObservableObject, Identifiable {
    let id: UUID
    @Published var title: String
    @Published var body: String
    //
}

extension Document: Named {
    var name: String { title }

    var details: String? {
        let t = body.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !t.isEmpty else { return nil }
        return String(t.prefix(56)) + (t.count > 56 ? "" : "")
    }
}

After that, Document satisfies NamedObject. Use @ObservedObject (or a store-owned instance) so rows refresh when @Published properties change.

Value types: same idea

struct Article: Identifiable, Codable, Hashable {
    let id: UUID
    var headline: String
    var markdown: String
}

extension Article: Named {
    var name: String { headline }

    var details: String? {
        let t = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !t.isEmpty else { return nil }
        return String(t.prefix(48)) + (t.count > 48 ? "" : "")
    }
}

Article is now a NamedValue: good for NavigationLink(value: article.id) and a detail screen that loads or edits the full value by ID. The list row stays dumb; detail owns the heavy payload.

Why extensions instead of parallel models

You avoid a shadow struct that must track every field rename. Preview strings and truncation live next to other UI-facing concerns, while generics stay readable as T: NamedObject or T: NamedValue. If Named already exists in your module, add only the conformances and the row.

Wiring a list and detail (sketch)

Value-based navigation keeps the list stable: rows show Named, destinations resolve by ID. A minimal shape looks like this—adapt the store to your app.

List {
    ForEach(shelf.articles) { article in
        NavigationLink(value: article.id) {
            NamedRow(model: article)
        }
    }
}
.navigationDestination(for: UUID.self) { id in
    if let article = shelf.articles.first(where: { $0.id == id }) {
        // full editor or reader
    }
}

For NamedObject, the row usually wraps @ObservedObject var document: Document and passes document into NamedRow(model:) so updates flow from the shared instance.

If you combine selection and edit mode with NavigationLink, see SwiftUI List: selection, editMode, and NavigationLink for a pattern that avoids edit mode and taps fighting each other.