Swift DSLs and a Union Type Macro

Last month, I wrote about trying out Factor, and then settling on Ruby, to build a new generator for this blog. I’m an iOS engineer, so… why not Swift? Well, the short answer: Ruby is cool too, and I’d got my Swift fix at work that day.

I have built static website generators with Swift before though. One of them was built on top of John Sundell’s Plot, a Swift DSL for HTML, which I expanded with a SwiftUI-like styling system, so that both structure and style were type-safe and lived in Swift. This post is about that project, Swift DSLs, footguns, and shiny macros.

Plot

The Plot framework is exactly what you would expect if you’re familiar with SwiftUI. It’s simply a catalogue of Swift components which map to HTML tags and can be composed via a result builder (it actually pre-dates result builders, so there’s also an interesting API for composing the components via variadics).

  struct NewsArticle: Component {
    var imagePath: String
    var title: String
    var description: String

    var body: Component {
      Article {
        Image(url: imagePath, description: "Header image")
        H1(title)
        Span(description).class("description")
      }
      .class("news")
    }
  }
Example via Plot's repo.

The advantage vs templating (eg HTML with embedded Ruby) is pretty clear: type-safety, and specifically, that the relationship between all your ‘templates’ is explicit without any extra work. You’re also getting a nice improvement to readability (at least from the perspective of a Swift engineer). This advantage is at the expense of introducing an additional layer between you and what your users see: without making a mess, we’ve limited our flexibility to exclusively what the framework allows. That said, everything that I wanted to do with Plot was possible; going off the beaten track wasn’t easy, but it wasn’t prohibitively damaging to code quality (hacky) either.

One big difference between Plot and SwiftUI’s DSL is styling. In idiomatic SwiftUI, styling is part of your views; in Plot, you’re expected to use CSS files. I much prefer SwiftUI’s way. I don’t believe that decoupling structure and style is always useful (imo it hurts cohesion and clarity a lot of the time, especially for high level compositions), and SwiftUI still allows us that separation when it is useful (ButtonStyle etc). We already have reuse here via defining new components/views, and… a CSS-shaped hole in our new type-safety would feel bad. Therefore, I decided to have a go at extending Plot with a styling DSL.

Extending Plot with styling

Plot lets you apply a style class via the class modifier, or inline styles with the style modifier. The most straightforward way to add a styling DSL would be to hook into one of these options. Naively, I decided to use inline styling to simplify the implementation since the generator would already handle reuse. We wouldn’t need to implement some extra step to compile and then link CSS classes, and we’d instead just be writing some convenience methods. Should be easy!

The first hurdle: composition

When the .style modifier is used multiple times on the same element, only the value of the last call is used when rendering. Mirroring SwiftUI’s aesthetics might not be easy after all…

extension Component {
  func padding(_ amount: Int) -> Component {
    style("padding: \(amount)px")
  }

  func margin(_ amount: Int) -> Component {
    style("margin: \(amount)px")
  }
}

#Playground {
  let test = H1("Title")
    .padding(10)
    .margin(5)
  print(test.render()) // Prints "<h1 style="margin: 5px">Title</h1>"
}

We’re in luck though; both class and style are just convenience methods wrapping the modifier attribute, and attribute has a handy optional param: replaceExisting. When set to false, previously set attribute values will be combined! Annoyingly, the delimiter is hard-coded as a space (a good example of the awkwardness you can expect vs ERB); in our case though, we can just stick a semicolon onto the end of every value we set, since it will be ignored when unnecessary.

extension Component {
  func padding(_ amount: Int) -> Component {
    attribute(
      named: "style",
      value: "padding: \(amount)px;",
      replaceExisting: false
    )
  }

  func margin(_ amount: Int) -> Component {
    attribute(
      named: "style",
      value: "margin: \(amount)px;",
      replaceExisting: false
    )
  }
}

#Playground {
  let test = H1("Title")
    .padding(10)
    .margin(5)
  print(test.render()) // Prints "<h1 style="padding: 10px; margin: 5px;">Title</h1>"
}

If we add some structure so the semicolon can’t be easily forgotten about (even just a helper method between attribute and our high level styling methods), this would work well… but, only just. Because we’re forced to use a String to encode the style prior to rendering, we’re very limited in how we could expand the DSL.

Plot’s escape hatch

Like SwiftUI, Plot is built on a backbone of protocols (Component being roughly equivalent to View), and therefore affords us a lot of the same patterns for expansion: we can create a bespoke Component which aggregates multiple styles.

typealias StyleMap = OrderedDictionary<String, String>

private struct StyledComponent: Component {
  let styling: StyleMap
  let wrappedComponent: Component

  var body: Component {
    wrappedComponent.style(
      styling
        .map { name, value in
          "\(name): \(value)"
        }
        .joined(separator: "; ")
    )
  }
}

extension Component {
  func style(with styling: StyleMap) -> Component {
    if let self = self as? StyledComponent {
      StyledComponent(
        styling: self.styling.merging(styling, uniquingKeysWith: { $1 }),
        wrappedComponent: self.wrappedComponent
      )
    } else {
      StyledComponent(styling: styling, wrappedComponent: self)
    }
  }
}

#Playground {
  let test = H1("Title")
    .style(with: ["padding": "10px"])
    .style(with: ["margin": "5px"])
  print(test.render()) // Prints "<h1 style="padding: 10px; margin: 5px">Title</h1>"
}

We’re getting somewhere! The example above is functionally very similar to just using attribute and replaceExisting, but is far easier to expand. You might prefer replaceExisting for its simplicity if it meets your requirements; in my case, I wanted to hook into JavaScript events such as onmouseenter to enable conditional styling. I used bespoke models for each style and each event kind, then kept a dictionary of styles keyed by event (or the absence of an event) within my Component, allowing everything to be neatly compiled during rendering. Skipping ahead, I ended up with something like this:

Screen recording mousing over links that change colour.

func MouseOverLink(
  _ text: String,
  url: URLRepresentable,
  initialColour: Colour = .link,
  mouseOverColour: Colour = .mouseOverColour
) -> Component {
  Link(text, url: url)
    .colour(initialColour) // My blog is `lang="en-GB"`.
    .styled(with: .colour(mouseOverColour), when: .mouseEnter)
    .styled(with: .colour(initialColour), when: .mouseLeave)
    .transition(duration: .seconds(0.5), animation: .ease)
}

Iconoclastically 🙃, I used upper camel case functions to define my high level Components by default; there is no inherent advantage to using struct here, unlike with SwiftUI where it matters for view evaluations, and I appreciate the conciseness.

Edge cases

let test = Text("test")
  .style(with: ["color": "red"])
print(test.render())

only prints “test”…

The attribute modifier silently fails for Text. Text is just text, it’s not a tag, so it can’t have attributes. We could at least add a warning.

extension Text {
  @available(*, deprecated, message: "Styling is not supported for Text")
  func style(with styling: StyleMap) -> Component {
    fatalError("Styling is not supported for Text")
  }
}

let test = Text("test")
  .style(with: ["color": "red"]) // "'style(with:)' is deprecated: Styling is not supported for Text"
print(test.render())

Better than nothing, but it can be easily bypassed by casting as Component (which isn’t unlikely given Plot’s reliance on type erasure), and we’d need to duplicate it for every single convenience modifier we add which wraps style. I prefer to implicitly promote the Text into something useful via our modifier.

extension Component {
  func style(with styling: StyleMap) -> Component {
    if let self = self as? StyledComponent {
      StyledComponent(
        styling: self.styling.merging(styling, uniquingKeysWith: { $1 }),
        wrappedComponent: self.wrappedComponent
      )
    } else if let self = self as? Text {
      Span {
        self
      }.style(with: styling)
    } else {
      StyledComponent(styling: styling, wrappedComponent: self)
    }
  }
}

We now print <span style="color: red">test</span>, which works for me, and is very probably the intent of styling Text anyway.

There are other rough edges too:

  • Setting the same style twice would only keep the value from the second call (wrapping with Div/Span every time might fix this by better matching SwiftUI, but at the cost of gnarliness).
  • Interweaving a non-styling modifier between styling modifiers would cause the first to be overwritten (the second is no longer called on a StyledComponent).

Both likely solvable, but at that point, forking Plot would probably make more sense: we’re starting to couple to implementation details anyway, and a more universal solution for these issues likely lies within Plot’s core. For my purposes, what we have already is good enough.

Making it pretty

Now that we’ve got the bones of our styling DSL ready, all that’s left is to create a nice high level interface. To support implementation of the conditional styling I mentioned above, I defined a simple struct to encode a style name/value pair, and built my API around that rather than dictionaries (this would be a worthwhile refactor regardless, gaining greater type-safety).

struct Styling {
  let name: String
  let value: String
}

extension Component {
  func styled(with style: [Styling], when event: ScriptEvent? = nil) -> Component { /* ... */ }
  func styled(with style: Styling..., when event: ScriptEvent? = nil) -> Component { /* ... */ }
}

I would define each modifier as an extension to Styling, before adding a convenience method to Component for it - not vital, but a little less coupled, and I appreciated the organisation.

enum Alignment: String {
  case left, right, center
  // ...
}

extension Styling {
  static func textAlign(_ alignment: Alignment) -> Self {
    .init(name: "text-align", value: alignment.rawValue)
  }
}

extension Component {
  func textAlign(_ alignment: Alignment) -> Component {
    styled(with: .textAlign(alignment))
  }
}

And with that, we’re pretty much there. We can both lay out and style our content in a way that would make any iOS engineer feel warm and fuzzy.

Text("Hello, world!")
  .bold()
  .textAlign(.center)
  .colour(.red)

As I continued to work on the project (the real one: the actual website… which I had barely started), I would define new styling methods as I needed them. As above, I would use enums for named style values, and for unit kinds.

enum Length: CustomStringConvertible {
  case px(Int)
  case em(Double)
  // ...

  var description: String {
    switch self {
    case let .px(value): "\(value)px"
    case let .em(value): "\(value)em"
    // ...
    }
  }
}

As I went on, I noticed lots of styles share 99% of the same value kinds, but not 100%. For example, margin can be given the value auto, border can’t. Unfortunately, Swift doesn’t have a perfect option for expressing this. Most would reach for enums, but that would incur frequent boilerplate in this case, and wouldn’t provide polymorphism out-of-the-box, somewhat spoiling the readability of our DSL (H1.margin(.notAuto(.px(10))); we could find a better case name than notAuto… but you get the idea).

Shiny macros (simulated union types 😎)

Since I was working on this project in early 2024, I wanted to play with Swift’s cool new macros support! I definitely wouldn’t recommend this approach in real projects without a lot of evidence that it would scale… but it’s nice to know we have this kind of power if we ever need it!

The bit that bugged me about using enums was mainly that, without defining helpers (static methods) on the main sum type for each of variant enum’s own cases, we’d have to spell out the sum type’s case wrapping the variant at each call site. Could we write a macro to automate creating helper methods? The answer is yes!

@InheritableCases
enum Length: CustomStringConvertible {
  case px(Int)
  case em(Double)
  case rem(Double)
  // ...
}

This would create a peer protocol:

@_Inheriting(cases: ("px", ["Int"]), ("em", ["Double"]), ("rem", ["Double"]))
protocol LengthInheritor {
  static func convert(ancestor: Length) -> Self
}

What’s @_Inheriting? It’s an extension macro which creates the helper methods we want in an extension to the protocol. The tuples passed to cases are a way to pass the case names and the types of their associated value(s) to the next stage (perhaps there’s a cleaner alternative to using Strings - I’ve not got this deep with macros since - but they did the job fine).

extension LengthInheritor {
  static func px(_ t0: Int) -> Self {
    convert(ancestor: .px(t0))
  }
  static func em(_ t0: Double) -> Self {
    convert(ancestor: .em(t0))
  }
  static func rem(_ t0: Double) -> Self {
    convert(ancestor: .rem(t0))
  }
}

Swift macros are very restrictive, and can’t create extensions to types other than the one they’re applied to (it’s why my macro is applied to the variant’s type, rather than to the sum type… and probably a reason why we’ve still not seen a huge number of interesting macros out in the wild). The only way I could figure out to accomplish what I wanted was to create a macro which applies a second macro itself (if anyone has discovered a better alternative, I’d love to chat about it!). At the time, I even needed to download the latest toolchain just to get this approach to compile; it was still bugged in the shipping Xcode as late as April 2024.

We now just need to define our sum type and adopt the generated protocol!

enum LengthOrAuto: LengthInheritor, CustomStringConvertible {
  case auto
  case length(Length)

  static func convert(ancestor: Length) -> LengthOrAuto {
    .length(ancestor)
  }

  var description: String {
    switch self {
    case .auto: "auto"
    case let .length(length): length.description
    }
  }
}

print(
  H1("Hello")
    .margin(.all, .auto) // (I also used an `OptionSet` to declare the edge to pad, mirroring SwiftUI)
    .render()
) // <h1 style="margin-bottom: auto; margin-left: auto; margin-top: auto; margin-right: auto">Hello</h1>
print(
  H2("World!")
    .margin([.top, .bottom], .px(10))
    .render()
) // <h2 style="margin-bottom: 10px; margin-top: 10px">World!</h2>

Final thoughts

I think it’s clear that for a single small static website, using Plot, and expanding it as much as I did, was completely overkill (I knew that going in though… it was still a lot of fun). Since my resurrected blog is a website I’d like to maintain long term, ERB was definitely the right choice. That said, I think there is a huge potential for a fully featured Swift DSL for this stuff; if a company invested heavily in one, or an open source project took off, the rough edges could all be smoothed out, and you’d be left with something that I think could meaningfully improve developer experience and efficiency. The biggest issue I ran into during the project was just a result of using inline styling; I didn’t have much web experience at all until earlier this year (when I was thrown in at the deep end of a massive React codebase), and didn’t realise just how limiting it can be (it’s why I ended up using JS events). With some significant upfront work, I imagine it would be possible to switch to compiling detached CSS without impacting the expressiveness of the DSL.