Result builders in Swift (2)
Second older post recovered, originally published in July 2022.
I have added an addendum at the end, generated by GPT o3, commenting on the changes introduced in Swift over the last three years that affect the material discussed in the article.
In the previous post in the series on result builders, we saw how they allow us to use a DSL to define a closure or block of code that builds a component from elementary components.
We saw the simple example of a string builder:
@resultBuilder
struct StringConcatenator {
static func buildBlock(_ components: String...) -> String {
return components.joined(separator: ", ")
}
}
The code above creates the @StringConcatenator annotation that we can use to apply the result builder. For example, we can apply it to a function definition:
@StringConcatenator
func holaMundo() -> String {
"Hola"
"mundo"
}
print(holaMundo())
// Prints: Hola, mundo
The function above constructs a string by joining the elementary strings defined in its body. Let us remember that the result builder transforms this body at compile time, turning it into something like this:
func holaMundo() -> String {
let v0 = "Hola"
let v1 = "mundo"
return StringConcatenator.buildBlock(v0, v1)
}
Finally, we ended by explaining that if we annotated a function parameter with the attribute, the result builder would be applied to the closure passed as that parameter. This is interesting because it allows us to use the result builder without the annotation appearing explicitly:
func imprimeSaludo(@StringConcatenator _ contenido: () -> String) {
print(contenido())
}
// We call the function with a closure that uses the DSL.
// There is no need to add the @StringConcatenator annotation.
imprimeSaludo {
"Hola"
"mundo"
}
// Prints: Hola, mundo
In this second post we are going to look at other places where the result builder attribute can be used and other transformations that can be performed.
Result builders in initializers
In SwiftUI, the result builder ViewBuilder is used to construct views. An example is the following:
let vista =
HStack {
ForEach(
1...5,
id: \.self
) {
Text("Ítem \($0)")
}
}
The constructed view is a horizontal stack with five subviews of type Text:

We can see that HStack receives a closure with DSL code that specifies the subviews. ViewBuilder will transform that DSL into Swift code.
Why do we not have to use the @ViewBuilder attribute? The explanation is that this attribute has been used on a function parameter. More specifically, on a parameter of HStack’s initializer.
Let us do something similar with StringConcatenator.
Example of a result builder in an initializer
Suppose we have the following Persona structure:
struct Persona {
let contenido: () -> String
var saludo: String {
contenido()
}
init(@StringConcatenator contenido: @escaping () -> String) {
self.contenido = contenido
}
}
We are defining a structure with a stored property, contenido, that contains a parameterless closure returning a string. And a computed variable, saludo, that returns the string obtained by executing that closure.
We also define the Persona initializer with the parameter that initializes the contenido property. To construct an instance of Persona we must pass as an argument the closure that will generate the greeting. And we add the @StringConcatenator attribute to that parameter to indicate that the argument we pass must be transformed by the result builder. The @escaping attribute is not important here; it has to do with how the closure’s scope is managed, and the compiler produces an error if we do not include it.
Now we can create an instance of Persona by passing a closure that uses the DSL:
let frodo = Persona {
"Hola"
"me"
"llamo"
"Frodo"
}
Once the instance has been constructed, the closure that returns the greeting will have been stored in its contenido property. We call the closure by accessing the saludo property:
print(frodo.saludo)
This prints:
Hola, me, llamo, Frodo
Simplifying the initializer
The engineers who designed result builders came up with a piece of syntactic sugar that makes the construction above even simpler.
Since Swift structures automatically generate a memberwise initializer, we could use the result builder attribute directly on the property. We do not need to define the initializer because Swift creates it automatically:
struct PersonaSimple {
@StringConcatenator let contenido: () -> String
var saludo: String {
contenido()
}
}
There is no need to specify anything else. Swift automatically generates the structure’s initializer correctly, and we can use it in the same way as before:
let frodo2 = PersonaSimple {
"Hola"
"me"
"llamo"
"Frodo"
}
print(frodo2.saludo)
// Prints: Hola, me, llamo, Frodo
This way of defining a result builder is one of the most common. It is used in the vast majority of DSLs built in Swift, including SwiftUI.
Result builders in protocols
Another way to apply a result builder without explicitly using its annotation is through a protocol. If we mark a method or property in a protocol with the annotation, the result builder will be applied in the code that adopts the protocol.
Let us continue with the example of the greeting built with @StringConcatenator. We can define a protocol with a property for the greeting:
protocol Educado {
@StringConcatenator var saludo: String {get}
}
When the property is defined this way, any type that adopts the Educado protocol will have to define a saludo property in which the result builder can be used. For example, we can define the PersonaEducada structure like this:
struct PersonaEducada: Educado {
var nombre: String
var saludo: String {
"Hola"
"me"
"llamo"
nombre
}
}
We are defining saludo with the strings shown in the different statements ("Hola", "me", "llamo") and the nombre property. The @StringConcatenator result builder will transform this code in the way we saw earlier.
Since saludo is a computed variable, the only stored variable that needs to be specified when creating the structure is the person’s nombre. We do so in the following way, calling the automatically created memberwise initializer:
let gandalf = PersonaEducada(nombre: "Gandalf")
And once the instance of PersonaEducada has been created, we can ask for its greeting:
print(gandalf.saludo)
As always, it will print:
Hola, me, llamo, Gandalf
More elaborate transformations
So far we have seen how the result builder constructs a complex component from elementary components by using the static function buildBlock.
The signature of this function is as follows:
static func buildBlock(_ components: Component...) -> Component
In the case of the examples above, the component type is String and the buildBlock function receives a variable number of strings and constructs the resulting string.
However, in some DSLs we may need to perform some kind of transformation on the initial components. Or apply a final transformation to the resulting value. To gain this finer-grained control we can specify two additional functions in the result builder: buildExpression and buildFinalResult.
Their signatures are as follows:
static func buildExpression(_ expression: Expression) -> Component
static func buildFinalResult(_ component: Component) -> FinalResult
-
The
buildExpression(_ expression: Expression) -> Componentfunction is used to transform the results of DSL statements, from the Expression type into the resulting Component type that will be used inbuildBlock. It allows the type of the expressions appearing in the DSL to be different from the resulting type. -
The
buildFinalResult(_ component: Component) -> FinalResultfunction is used to construct the final result that the result builder will return. It lets us distinguish the component type from the result type so that, for example, the result builder may perform internal transformations on a type we do not want to expose to clients and then finally transform it into the resulting type.
These functions are optional. If we do not specify them, the result builder works only with the Component type, as we saw in the examples above.
A simple example is the following one, in which we define a result builder that constructs an array of real numbers. The expressions we write in the DSL are integers.
@resultBuilder
struct ArrayBuilder {
static func buildExpression(_ expression: Int) -> [Int] {
return [expression]
}
static func buildBlock(_ components: [Int]...) -> [Int] {
return Array(components.joined())
}
static func buildFinalResult(_ component: [Int]) -> [Double] {
component.map {Double($0)}
}
}
-
The
buildExpressionfunction transforms the original integer into an array with a single element. In this case the Expression type isIntand the resulting Component type is[Int]. -
buildBlockis the function that joins several components, one-element integer arrays, into a final result: an integer array. -
And the
buildFinalResultfunction transforms the component produced by the previous function into the FinalResult type,[Double].
We can see the result of how it works in the following example:
@ArrayBuilder
func buildArray() -> [Double] {
100
100+100
(100+100)*2
}
print(buildArray())
In the DSL that defines the function body, we write three statements that return integers. Those three statements are the expressions the result builder will use to apply all the transformations above.
The final result is the following array of real numbers:
[100.0, 200.0, 400.0]
References
- Result Builders proposal in Swift Evolution
- Detailed explanation in the Language Reference
- Code file with the examples from the post
Addendum (April 2025) - What has happened in Swift over these three years?
Quick context
Since you published this second article in 2022, Swift has kept evolving.
This appendix summarizes the changes that affect the topics discussed here: initializers, protocols, and advanced functions such asbuildExpressionandbuildFinalResult.
1. Initializers + result builder -> now also in classes
Swift 5.8 expanded the ability to mark designated class initializers with builder attributes. An example adapted to your Persona:
class Persona {
private let contenido: () -> String
var saludo: String { contenido() }
init(@StringConcatenator contenido: @escaping () -> String) { // valid in 5.8+
self.contenido = contenido
}
}
2. Memberwise + attributes: they are generated automatically
Starting with Swift 5.9, when you annotate a stored property with a builder, for example @StringConcatenator let contenido: () -> String, the compiler no longer requires you to mark the corresponding parameter in the memberwise initializer with the same attribute; it does that on its own.
struct PersonaSimple {
@StringConcatenator let contenido: () -> String // enough on its own
}
3. Protocols with builders: they now support async/throws
With the adoption of Strict Concurrency in Swift 5.10, protocol requirements can be declared like this:
protocol Educado {
@StringConcatenator var saludo: String { get async }
}
Whoever implements the protocol will be able to use a builder and also return an asynchronous value.
4. New intermediate-stage functions
Swift 5.7 introduced buildPartialBlock(first:) and buildPartialBlock(accumulated:).
If you implement them, you can omit buildBlock, and the compiler will assemble the result incrementally, which is useful for performance in heavy builders.
static func buildPartialBlock<each T>(first value: repeat each T) -> (repeat each T) { value }
static func buildPartialBlock<each T>(accumulated: (repeat each T), next: (repeat each T)) -> (repeat each T) {
(repeat each accumulated, repeat each next)
}
Tip: with parameter packs (
<each T>) you do not need overloads for 1…10 elements.
5. buildExpression + error propagation
If your buildExpression can throw, you can now mark it as throws in Swift 5.9.
The error propagates to the point where the builder is used; it does not need to be caught inside the builder.
static func buildExpression(_ value: Int) throws -> [Int] { ... }
6. Macros vs. Result Builders (brief reminder)
The new era of Swift Macros (SE-0389/0397) does not replace builders, but it does cover cases that we previously forced with them:
| What I want to achieve | Builder | Macro |
|---|---|---|
| Declarative DSL (SwiftUI, HTML…) | Yes | Yes |
Generate new declarations, wrappers, automatic Codable, etc. |
No | Yes |
| Full AST validation at compile time | No | Yes |
To keep digging deeper
- SE-0390 - Variadic Generics (parameter packs)
- SE-0389 / SE-0397 - Swift Macros
- WWDC23 “Design Data-Driven Apps with Result Builders”