From af82c52dca244b4fd2e836961da3c676e8b97e7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 01:09:46 +0000 Subject: [PATCH 1/2] Improve README opening with Button example and constrained extension context Explains the view slot pattern using SwiftUI Button as a concrete example, shows how constrained extensions power string convenience inits, and illustrates why the approach scales poorly to multi-slot components. https://claude.ai/code/session_0146841j3MHfYwLTTA1Mnd6i --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fa879d6..5c85725 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,60 @@ A Swift macro for building SwiftUI design system components with generic view sl ## The problem -A well-designed SwiftUI component accepts generic `View` parameters for its customizable regions ("slots"), so callers can pass anything from a `Text` to a fully custom view. But offering sane defaults — like an init that takes a plain string — creates an exponential blowup of handwritten initializers as slot count grows. +SwiftUI's `Button` is a great example of a well-designed component. Its primary initializer accepts a `@ViewBuilder` closure for its label slot, so you can pass anything as the label: -A two-slot component with one optional slot and one text-convenience slot already needs four inits. Three slots needs twelve. And every time you add a slot you have to update them all. +```swift +Button(action: signIn) { + HStack { + Image(systemName: "arrow.right.circle") + Text("Sign In") + } +} +``` + +But `Button` also works with a plain string: + +```swift +Button("Sign In", action: signIn) +``` + +That convenience doesn't come for free. Under the hood, SwiftUI ships two extra initializers in constrained extensions — one for `LocalizedStringKey` (preferred) and one for `String` (disfavored), both pinning `Label == Text`: + +```swift +// The generic init — lives on the struct itself +init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) + +// Convenience inits — live in a constrained extension +extension Button where Label == Text { + init(_ titleKey: LocalizedStringKey, action: @escaping () -> Void) + + @_disfavoredOverload + init(_ title: S, action: @escaping () -> Void) +} +``` + +That's manageable for a single slot. But real design-system components often have more — a card with a title, a subtitle, and an actions region; a row with a leading icon and a trailing accessory. Every slot that should accept a plain string needs its own `where` clause, and every optional slot needs its own omission variant. The combinations multiply fast. + +A two-slot component with one optional slot and one text-convenience slot already needs four inits: ```swift -// Just two slots. Already four inits to write and maintain. struct Card: View { + // On the struct init(@ViewBuilder title: () -> Title, @ViewBuilder actions: () -> Actions) { ... } + + // extension Card where Title == Text init(title: LocalizedStringKey, @ViewBuilder actions: () -> Actions) { ... } + + // extension Card where Actions == Never init(@ViewBuilder title: () -> Title) where Actions == Never { ... } + + // extension Card where Title == Text, Actions == Never init(title: LocalizedStringKey) where Actions == Never { ... } } ``` +Add a third slot and you're writing twelve inits. Add a fourth and it's more than thirty. Every new slot means updating every existing combination. It's mechanical, error-prone, and no fun. + ## The solution Annotate your component with `@Slots`. Optional generic properties are automatically recognized as slots; use `@Slot` only when you need options like `.text`. The macro generates every init permutation for you — fully type-safe, using constrained extensions with no casts. From 893e7d2591698b249d55cfcb81f630a940c22924 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 01:13:04 +0000 Subject: [PATCH 2/2] Expand optional slots section with Never-based optionality explanation Explains why EmptyView is a lossy workaround, how constraining absent slots to Never encodes presence in the type system, and how Slots uses this to generate clean omission inits with no runtime type checking required. https://claude.ai/code/session_0146841j3MHfYwLTTA1Mnd6i --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c85725..70ac3a1 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,44 @@ The `@Slot` property annotation accepts one or more options: ### Optional slots +A common pattern for optional views in SwiftUI is to accept `some View` and have callers pass `EmptyView()` when they don't want anything rendered: + +```swift +// Caller is forced to explicitly say "nothing here" +MyBadge(icon: EmptyView(), label: "New") +``` + +This works, but it loses information. Inside the component you might want to skip surrounding layout — padding, a divider, a spacer — when the icon is absent. With `EmptyView` you can't know at the type level whether the caller intentionally omitted the icon or just happened to pass an empty view. You'd have to reach for runtime type inspection (`Mirror`, `is EmptyView`) or `AnyView`, both of which are worse than having no slot at all. + +The better approach is to make the slot's type `Optional`. When the slot is absent, the type is constrained to `Never` — and because `Never` has no values, an `Optional` can only ever be `nil`. There's nothing to check at runtime; absence is encoded in the type itself. + +Inside the component body, this falls out naturally: + +```swift +if let icon { icon } // skipped entirely when Icon == Never +``` + +And because absence is a compile-time fact, you can write constrained extensions that are only available when the slot is missing — or, more usefully, require the slot to be present: + +```swift +// Only available when there's no icon +extension Badge where Icon == Never { + func withDefaultIcon() -> some View { ... } +} +``` + Declare a slot as optional by using `?` in the property type — no `@Slot` annotation needed: ```swift var icon: Icon? ``` -This automatically generates an init variant that omits the parameter entirely, storing `nil`. The absent slot type is constrained to `Never`. +Slots generates an init variant that omits the parameter entirely and stores `nil`, constraining `Icon == Never` in the where clause of that extension. Call sites just leave the argument out: + +```swift +Badge(label: "New") // Icon == Never; icon is always nil +Badge(label: "New") { Image(systemName: "star") } // Icon == Image; icon is non-nil +``` ### Example