A Swift macro for building SwiftUI design system components with generic view slots — without the init explosion.
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.
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.
// Just two slots. Already four inits to write and maintain.
struct Card<Title: View, Actions: View>: View {
init(@ViewBuilder title: () -> Title, @ViewBuilder actions: () -> Actions) { ... }
init(title: LocalizedStringKey, @ViewBuilder actions: () -> Actions) { ... }
init(@ViewBuilder title: () -> Title) where Actions == Never { ... }
init(title: LocalizedStringKey) where Actions == Never { ... }
}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.
import Slots
@Slots
struct Card<Title: View, Actions: View>: View {
@Slot(.text)
var title: Title
var actions: Actions?
var body: some View { ... }
}That's it. The macro expands to:
// On the struct — caller provides any View via @ViewBuilder closure
init(@ViewBuilder title: () -> Title, @ViewBuilder actions: () -> Actions)
// extension Card where Title == Text
init(title: LocalizedStringKey, @ViewBuilder actions: () -> Actions)
@_disfavoredOverload
init(title: String, @ViewBuilder actions: () -> Actions)
// extension Card where Actions == Never
init(@ViewBuilder title: () -> Title)
// extension Card where Title == Text, Actions == Never
init(title: LocalizedStringKey)
@_disfavoredOverload
init(title: String)Call sites stay clean and exactly what you'd expect:
Card(title: "Hello") { likeButton } // LocalizedStringKey + custom view
Card(title: "Hello") // LocalizedStringKey, no actions
Card { headerView } actions: { likeButton } // custom view + custom view
Card { headerView } // custom view, no actionsThe @Slot property annotation accepts one or more options:
| Option | Effect |
|---|---|
.text |
Adds init variants where this slot accepts LocalizedStringKey (preferred) or String (disfavored), both stored as Text(...) |
Declare a slot as optional by using ? in the property type — no @Slot annotation needed:
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
struct Badge<Icon: View, Label: View>: View {
var icon: Icon?
@Slot(.text) var label: Label
var body: some View { ... }
}Generated inits:
// Base
init(@ViewBuilder icon: () -> Icon, @ViewBuilder label: () -> Label)
// extension Badge where Label == Text
init(@ViewBuilder icon: () -> Icon, label: LocalizedStringKey) // preferred
@_disfavoredOverload
init(@ViewBuilder icon: () -> Icon, label: String) // disfavored
// extension Badge where Icon == Never
init(@ViewBuilder label: () -> Label)
// extension Badge where Icon == Never, Label == Text
init(label: LocalizedStringKey) // preferred
@_disfavoredOverload
init(label: String) // disfavoredCall sites:
Badge(label: "New") // LocalizedStringKey, no icon
Badge(label: "New" as String) // explicit String, no icon
Badge(icon: { Image(systemName: "star") }, label: "New") // icon + LocalizedStringKey
Badge { starView } label: { customLabel } // fully genericNon-slot stored properties are included as labeled parameters in every generated init, before the slot parameters. Properties with a default value carry that default in the generated signature:
@Slots
struct Row<Content: View>: View {
var isSelected: Bool // no default → required in every init
var badge: Int = 0 // has default → optional param in every init
@Slot(.text) var content: Content
var body: some View { ... }
}
// Generated:
init(isSelected: Bool, badge: Int = 0, @ViewBuilder content: () -> Content)
// extension Row where Content == Text
init(isSelected: Bool, badge: Int = 0, content: LocalizedStringKey)
@_disfavoredOverload
init(isSelected: Bool, badge: Int = 0, content: String)Add the package in Xcode via File → Add Package Dependencies, or in Package.swift:
dependencies: [
.package(url: "https://github.com/kylebshr/slots.git", from: "0.1.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
.product(name: "Slots", package: "slots"),
]),
]Then import and use:
import Slots
import SwiftUI
@Slots
struct MyComponent<...>: View { ... }@Slots is an @attached(member) + @attached(extension) macro. Non-required generic properties (those typed as optional generics like Icon?) are automatically treated as slots. The @Slot annotation is only needed to specify options.
- The member expansion adds the base all-generic
initdirectly on the struct, with each slot parameter as a@ViewBuilderclosure. - The extension expansion generates one
extension MyComponent where ...per unique combination of fixed slot types. Grouping by where-clause meansLocalizedStringKeyandStringvariants for the same slot share a single extension with twoinitoverloads.