Swift Result Builders: Creating Custom DSLs for Binary Formatted Data
SwiftUI has revolutionized how we build UI, introducing a more intuitive, declarative approach. Instead of prescribing a series of steps to reach an end goal, we describe the outcome and let the program determine the path. But can we apply this principle to other areas such as binary data by using custom result builders? Like UI, data transformation often involves moving from a specific starting point to a desired end state. Just imagine, if we could simply define the endpoints and let the program do the heavy lifting! In this article we take a deep dive into the result builders, the magic behind SwiftUI, and how we can apply the same principles to other areas by creating custom result builders to encode binary-formatted data.
With declarative frameworks taking over UI development (including React, Flutter, Compose, SwiftUI, etc), more and more developers become used to declarative programming. When coming from object-oriented, imperative, or functional paradigms, declarative programming can be quite challenging for developers to get used to. Since UI development is an important field of mobile software development though and many developers are now gaining knowledge in declarative programming, let’s take a look at whether it makes sense to use functional programming in even more parts of our apps. How can one make use of Swift’s result builder feature to make it easier to encode binary-formatted data as it may be used for communication via Bluetooth / Internet or in Peer-to-Peer networking?
Note: Alongside this article, we developed a library to easily encode and decode binary-formatted data in Swift – DataKit. With the help of modern Swift language features, it allows developers to easily specify the binary format of their message types using a declarative style. Feel free to check it out and let us know what you think!
Declarative Programming
First of all: What actually is declarative programming? In programming, there are often many different paths to solve a problem – many of these paths can be grouped into different paradigms, including the imperative, object-oriented, functional, or declarative programming paradigm.
Imperative
In imperative programming, your code will most likely look similar to a cooking recipe with a list of ingredients (variables) on top, followed by a set of instructions resulting in some output. The resulting code is often harder to reuse and possibly read, however, it is often used in more performance-critical, low-level portions of a codebase.
Object-oriented
In object-oriented programming, you create different types/objects to solve certain aspects of a program. Each object has its own responsibilities. A programmer usually aims to reduce dependencies between the different objects and keep each object on its own generic to improve the reusability and clarity of the code.
Functional
Functional programming tries to avoid the internal state by specifying instructions as mere transformations of the respective input in a rather mathematical form. Rather than using state variables or objects with internal storage, the output is purely defined by the input itself, often heavily relying on recursion, making the code easy to reason about and prove its correctness. Some algorithms relying on complex internal states might however lack readability and require more boilerplate code.
Declarative
Using declarative programming, code no longer specifies how to achieve a certain solution, but rather simply describes how the result should look like. How the actual solution is achieved is hidden away into different components that are composed to provide an intuitive and easy-to-read interface for developers. For example, in SwiftUI a developer only needs to specify the composition of a view from the current state and SwiftUI takes over the task of automatically updating the user interface – so there is no need to handle long-lasting view objects on your own and keeping state and views in sync.
ResultBuilder
Since declarative programming is based on the premise that describes the desired outcome directly, we first need some mechanism to specify that desired outcome. One common pattern to build such an object is the builder pattern. The builder pattern uses an object or type and its methods to construct a complex object (in our case the description of the desired outcome) by composing different objects. In Swift, this pattern can be implemented using so-called result builders.
ViewBuilder
If you have worked with SwiftUI before, you most likely have already used a result builder called ViewBuilder
. ViewBuilder
is used to compose different views inside the body
 property of a view. For everyone not familiar, here is a short example:
struct AppIconView: View { let hasText: Bool @ViewBuilder var body: some View { VStack(spacing: 16) { Image("AppIcon") if hasText { Text("<App Name>") } } } }
In this example, we can see a simple SwiftUI view. Many SwiftUI views are defined as a composition of other views inside their body
 properties. The @ViewBuilder
 annotation specifies that the ViewBuilder
result builder is to be used to construct the result of the body
property. Inside the body
property, you can see that the AppIconView
 is composed of a VStack
 containing an image and possibly also a text (depending on the value of hasText
).
Note: The @ViewBuilder
annotation does not need to be added for SwiftUI views explicitly, since the View
protocol already includes it. If you want to use a ViewBuilder
 for other properties, you need to specify it directly though.
Basic concept of ResultBuilder
For developers not used to result builders, the example above might not be intuitive. You simply create some objects and then throw them away directly? No, this is not what happens here. Instead, the Swift compiler will rewrite the method above into a representation like this:
var body: some View { ViewBuilder.buildBlock( VStack( spacing: 16, content: ViewBuilder.buildBlock( ViewBuilder.buildExpression(Image("AppLogo")), ViewBuilder.buildOptional( hasText ? ViewBuilder.buildExpression(Text("<App Name>")) : nil ) ) ) ) }
Essentially, result builders are a compiler feature that allows developers to avoid writing boilerplate code to compose different objects. Before we dive into what each of these methods means, let’s get a rough overview first.
As you can see in the example above, a result builder composes the resulting object by using multiple build functions. These functions can be clustered into three categories:
– buildExpression
 functions transform the values that can be fed into the result builder to components. They are super useful for providing many different input value types without unnecessarily overcomplicating the logic of a result builder.
– buildFinalResult
functions allow for components to be transformed into a final result type after the composition is finished. They are not required for all result builders but might be useful if the component type used during composition should be different from the return type.
– All other functions allow for components to be combined or transformed based on control flow. There are specific functions for if-statements, for-loops, etc.
Custom ResultBuilder: DataBuilder
Okay, enough theory – let’s build one! DataBuilder will allow us to easily encode binary formatted data. As an example, we imagine a weather station to regularly emit messages containing weather information that needs to be encoded using the new result builder. First, we will need to declare our result builder type:
@resultBuilder enum DataBuilder {}
A result builder is marked with a @resultBuilder
annotation. We use an enum without any cases to avoid instantiation of DataBuilder
. The implementation doesn’t build anything yet though – let’s change that!
extension DataBuilder { static func buildBlock(components: Data...) -> Data { components.reduce(into: Data()) { $0.append(contentsOf: $1) } } }
With this function, we are already able to compose different Data
components into one. But what about values that are not of type Data
?
Expressions
What about integer expressions for a start?
extension DataBuilder { public static func buildExpression<I: FixedWidthInteger>(_ value: I) -> Data { withUnsafeBytes(of: value) { Data($0) } } }
With this new function, DataBuilder
is able to convert all fixed-width integer types (i.e. UInt8, UInt16, UInt32, UInt64, UInt, Int8, Int16, Int32, Int64 and Int) into Data
 components. Therefore, there is no need to specifically handle integer types in the other build functions used for control flow statements.
Weather Station
Now with a basic result builder in place, we can take a look at what we want to encode: Update messages for a weather station. We have the following description of the message format:
- Each message starts with a byte with the value 0x02.
- The following byte contains multiple feature flags:
- bit 0 is set: Using °C instead of °F for the temperature
- bit 1 is set: The message contains temperature information
- bit 2 is set: The message contains humidity information
- Temperature as a big-endian 32-bit floating-point number
- Relative Humidity as UInt8 in the range of [0, 100]
- CRC-32 with the default polynomial for the whole message (incl. 0x02 prefix).
Based on this specification we have already built the following types:
struct WeatherStationFeatures: OptionSet { var rawValue: UInt8 static var hasTemperature = Self(rawValue: 1 << 0) static var hasHumidity = Self(rawValue: 1 << 1) static var usesMetricUnits = Self(rawValue: 1 << 2) } struct WeatherStationUpdate { var id: UInt16 var features: WeatherStationFeatures var temperature: Measurement<UnitTemperature> var humidity: Double // Range: [0, 1] }
As you can see, we have not only defined a WeatherStationUpdate
type, but also a WeatherStationFeatures
option set. Why is an option set? Because it allows us to easily check whether our features have a certain bit set (e.g. features.contains(.hasTemperature)
), use an array literal to construct our features ([.hasTemperature, .usesMetricUnits]
) or use set semantics(features.insersect([.hasTemperature, .hasHumidity])
). All of this functionality is provided to us by conforming to the OptionSet
 protocol. There is however one catch: Our struct will always only have that one rawValue. This limitation is totally fine in this case though.
To build our data from a WeatherStationUpdate
, we will add a data
 property and add our values as needed:
extension WeatherStationUpdate { @DataBuilder var data: Data { UInt8(0x02) features.rawValue } }
Okay, so the beginning of our message looks good, what about the temperature/humidity information?
Conditionals
Let’s improve DataBuilder
 to support if-statements!
extension DataBuilder { static func buildOptional(_ component: Data?) -> Data { component ?? Data() } static func buildEither(first component: Data) -> Data { component } static func buildEither(second component: Data) -> Data { component } static func buildLimitedAvailability(_ component: Data) -> Data { component } }
Wait, what? Four different functions for a simple if-statement? That can’t be right?! They all have slightly different purposes, but we can easily introduce them at once since they are quite similar.
- Â
buildOptional
is called for simple if-statements without anelse
block - Â
buildEither(first:)
is called for the first block of an if-statements with anelse
block or for the first case of a switch-case-statement - Â
buildEither(second:)
is called for either the `else` block of an if-statement or the remaining cases of a switch-case-statement. - Â
buildLimitedAvailability
is used for statements that are wrapped inside aif #available(...)
 construct.
Good, so now let’s add our temperature and humidity values to the data we are building:
extension WeatherStationUpdate { @DataBuilder var data: Data { UInt8(0x02) features.rawValue if features.contains(.hasTemperature) { if features.contains(.usesMetricUnits) { Float(temperature.converted(to: .celsius).value) .bitPattern.bigEndian } else { Float(temperature.converted(to: .fahrenheit).value) .bitPattern.bigEndian } } if features.contains(.hasHumidity) { UInt8(humidity * 100) } } }
Note: bitPattern
is a computed property on floating-point values that allows us to get an unsigned integer of the same size (i.e. Float16 –> UInt16, Float/Float32 –> UInt32, Double/Float64 –> UInt64). By specifying bigEndian
, we can easily convert any integer into its bigEndian representation no matter what the usual endianness of the system is. In our case here, we want all the floating-point numbers to be big-endian encoded.
With this change, we added both the temperature and humidity information. As written in our protocol specification, we only specify the temperature using the Celsius scale when the useMetricUnits
flag is set. Further, we need to encode the humidity using a UInt8
value. We are ignoring the possible conversion errors here for simplicity reasons and assume that they have been handled before the instantiation of the WeatherStationUpdate
 object itself.
Final result
Looking at the documentation, there is still one important thing missing: The CRC checksum at the end of the message. How do we implement that?! We could of course do something along the lines of this, right?
func appendCRC32(@DataBuilder to data: () -> Data) -> Data { var data = data() let crcValue = CRC32.default.calculate(for: data) withUnsafeBytes(of: crcValue) { data.append($0) } return data }
In this approach, we would create a method, that allows for some data to be passed inside. For this data, you would be able to use a DataBuilder
to build a result of the closure parameter – note that @DataBuilder
 annotation there! However, you would need to use another level of indentation when calling it and it’s not really intuitive, right?
Can’t we somehow make it work to just specify that CRC as a component? How could that CRC get to our data? Well, we can make our result builder more powerful by introducing a custom component type rather than Data
.
extension DataBuilder { struct Component { let append: (inout Data) -> Void } }
With this component type, we simply provide a closure that allows us to modify the existing data as it was written by other components. This way, our CRC checksum can easily read the existing data and then append its own value to the data.
We will, however need to rewrite our methods to fit the new component type. Since it is quite straight-forward, here is the result:
@resultBuilder enum DataBuilder { struct Component { let append: (inout Data) -> Void } static func buildBlock(_ components: Component...) -> Component { Component { data in for component in components { component.append(&data) } } } static func buildExpression<I: FixedWidthInteger>(_ expression: I) -> Component { Component { data in withUnsafeBytes(of: expression) { data.append(contentsOf: $0) } } } static func buildOptional(_ component: Component?) -> Component { component ?? Component { _ in } } static func buildEither(first component: Component) -> Component { component } static func buildEither(second component: Component) -> Component { component } static func buildLimitedAvailability(_ component: Component) -> Component { component } }
With these changes in place, we can start integrating the CRC now. In one of our previous articles, we have already implemented the CRC algorithm. You can find its implementation here.
To use this CRC algorithm in our result builder, let’s add another buildExpression
 function:
extension DataBuilder { static func buildExpression(_ crc: CRC32) -> Component { Component { data in let value = crc.calculate(for: data) withUnsafeBytes(to: value.bigEndian) { data.append(contentsOf: $0) } } } }
Perfect – now, we can simply use it in our data
 property, right? Let’s try! Since we have migrated all of our build functions, the existing implementation should still work fine, right? Oh no, it’s no longer compiling…
extension WeatherStationUpdate { @DataBuilder var data: Data { UInt8(0x02) features.rawValue if features.contains(.hasTemperature) { if features.contains(.usesMetricUnits) { Float(temperature).bitPattern.bigEndian } else { Float(32 + (9 / 5) * temperature).bitPattern.bigEndian } } if features.contains(.hasHumidity) { UInt8(humidity * 100) } CRC32.default } }
DataBuilder
 is now expecting us to return a DataBuilder.Component
value. Since we want it to build Data
 objects, let’s make use of the buildFinalResult
method of a result builder to build a Data
from a Component
 in our DataBuilder
.
extension DataBuilder { static func buildFinalResult(_ component: Component) -> Data { var data = Data() component.append(&data) return data } }
With this new function, DataBuilder
can easily build components using the Component
type and then transform that Component
in the last step to create the return value. We can now build the data from our WeatherStationUpdate
objects using declarative programming! 🎉
Note: Feel free to take a look at the enhanced implementation of DataBuilder
(and so much more) in our new Swift library DataKit.
Declarative Programming: Is it really all that useful?
That last version of the data
 property looks pretty much just like somehow just dumped the specification into some pseudocode, right? But is it all that different from an imperative version like this:
extension WeatherStationUpdate { var data: Data { var data = Data() data.append(0x02) data.append(features.rawValue) if features.contains(.temperature) { if features.contains(.usesMetricUnits) { data.append(Float(temperature).bitPattern.bigEndian) } else { data.append(Float(32 + (9 / 5) * temperature).bitPattern.bigEndian) } } if features.contains(.humidity) { data.append(UInt8(humidity * 100)) } data.append(CRC32.default.calculate(for: data)) return data } }
Note: This code snippet uses a custom-defined member function Data.append<I: FixedWidthInteger>(_: I)
. Below is the code-snippet if you want to use it yourself:
extension Data { mutating func append<I: FixedWidthInteger>(_ value: I) { withUnsafeBytes(of: value) { data.append(contentsOf: $0) } } }
For this, we didn’t need to introduce that DataBuilder
 type and not define all those functions at least. Let’s take a deeper look into how imperative code stacks up against this declarative code using Swift result builders!
Readability
Swift result builders help reduce boilerplate code for complex objects that are composed by other objects. DataBuilder
hides away all the custom data handling logic and allows us to simply specify what is supposed to be encoded, making the code more clean and focused on what’s really important.
Code Complexity
Result builders do bring quite a lot of code complexity into a code base. There are build functions to convert expressions into components, components into the final result, and all kinds of functions to allow different control-flow statements.
The verbose nature of result builders essentially limits their usage to the most common composition scenarios in our apps and most of the time, a simple, imperative function can be created in a less complex fashion than an entire result builder. In libraries, however, result builders could dramatically simplify the library interface.
Code Composition
Result builders nicely integrate nested compositions. In SwiftUI, the nesting of 2 or 3 ViewBuilders
deep across VStack
, HStack
, etc just feels natural. In imperative programming, however, you would most likely create a new builder object for each nesting, having a unique name and you would need to keep track of which builder object is responsible for each final composed object.
Compiler Support
Result builders can more easily create unexpected compiler errors.
- If a result builder function is declared in a different module as an extension and that module is currently not imported, the function might fail with an error message that is completely unrelated, because it can find no reasonable mix of functions that matches the given code.
- Result builders do not necessarily exhaustively search through each possible build function to get to the expected result type. Especially for expressions with generic constraints, a generic type might not be able to be inferred (even though it theoretically could be). Instead, each expression should pretty much already know its type by itself without relying on complex generic constraint chains through multiple levels of builder functions.
- There are scenarios, where result builders cannot decide which build function to take leading to a compilation error. It is sometimes necessary to annotate build functions as
@_disfavoredOverload
just to get the more generic function to not interfere with another specialized build function.
Lack of Composition Limitations
There are some scenarios in which a result builder is only supposed to contain a certain amount of components of a certain type. In these cases, it would often make more sense to not compose these components in a result builder but rather to provide them as parameters instead.
Code Comprehension
It is often quite simple to decipher what imperative code does. You can simply look up the methods being called and check at their documentation or even implementation. For result builders, the individual composition of build functions may, however, not be obvious. When there are multiple versions of buildBlock
functions with different type constraints to the component, it is not entirely clear, which one is called.
Learning Curve
Result builders and declarative programming in general, can present a considerable learning curve for a Swift developer who hasn’t worked with SwiftUI before or for developers transitioning from different platforms or programming languages.
Conclusion
Okay, so there are some upsides and some downsides to using result builders in Swift. When are they most useful though and when should I try to avoid them?
In general, result builders are quite a powerful tool to build domain-specific languages for composition scenarios that make up a large part of the functionality of a library. Sometimes, an object is composed of objects with the same or similar type and sometimes an object might simply be a collection of objects of the same type – or a mix of both, of course. This is where result builders really shine and make for great, easy-to-use code.
When you have additional requirements to the composition that result builders do not (yet) offer, it makes sense to avoid them and use proven other techniques instead. As a rule of thumb: If you only really use result builders, so that your interface is sleek and modern-looking, but you don’t really make use of advanced result builder features or have complex composition, a “traditional” parameter list is probably best.
Once result builders grow complex with many different expression and component types involved though, the compiler support becomes quite limited though and you will need to test your result builder code even more thoroughly to not run into issues where certain code snippets no longer compile.
For more information regarding result builders, you might want to have a look at the swift-evolution proposal that initially introduced them. If you are looking for a library to encode binary-formatted data in Swift, feel free to check out DataKit.