You are currently viewing Swift Result Builders: The Basics You Need to Know!

Swift Result Builders: The Basics You Need to Know!

Result builders (formerly known as function builders) is a new feature introduced in Swift 5.4, it is the technology that empowers ViewBuilder in SwiftUI. With the release of Xcode 12.5 (currently in beta stage), Apple has made it officially available for the developers, allowing us to create our own custom result builders for various kinds of use cases.

In this article, I would like to share with you the basic concept of result builders, how it works, and how you can use it to create your own custom result builders.

Without further ado, let’s get right into it!


The Basic Form

For demonstration purposes, let’s create a string builder that joins a sequence of strings using a "⭐️" as separator. For example, given "Hello" and "World", our string builder will return a joined string "Hello⭐️World".

Let’s start building our string builder using the most basic form of a result builder:

@resultBuilder
struct StringBuilder {
    
    static func buildBlock(_ components: String...) -> String {
        return components.joined(separator: "⭐️")
    }
}

As you can see, you can define a result builder by marking a custom struct with the @resultBuilder attribute and implementing the compulsory buildBlock(_:) static method.

The buildBlock(_:)method is like an entry point to our StringBuilder, it takes a variadic parameter of components, which means it can either be 1 or many strings. Within the buildBlock(_:)method we can do whatever processing we want on the given components, for our case we will join the given strings using "⭐️" as a separator.

When implementing the buildBlock(_:) method, there is a rule to follow: the returned data type must match with the components data type. Take the StringBuilder as an example, the buildBlock(_:) method component is of String type, therefore, its return type must be String as well.

To create an instance of StringBuilder, we can mark a function or variable using @StringBuilder:

// Mark a function as `StringBuilder`
@StringBuilder func buildStringFunc() -> String {
    
    // Here's the components area
    // ...
}


// Mark a variable as `StringBuilder`
@StringBuilder var buildStringVar: String {
    
    // Here's the components area
    // ...
}

Notice the components area stated above, it is the place where you feed the StringBuilder with the desired string. Each line in the components area will represent 1 component of the buildBlock(_:)‘s variadic parameter.

Let’s take the following StringBuilder as an example:

@StringBuilder func greet() -> String {
    "Hello"
    "World"
}

print(greet())
// Output: "Hello⭐️World"

it can be translated as:

func greetTranslated() -> String {

    // Resolve all partial components in `StringBuilder`
    let finalOutput = StringBuilder.buildBlock("Hello", "World")

    return finalOutput
}
print(greetTranslated())

Pro tip:

You can add a print statement inside the buildBlock(_:) method to see when it gets triggered and what components are provided at any given time.

That’s all it takes to create a result builder. Now that you have seen a basic result builder, let’s proceed to add more functionalities to our StringBuilder.


The Selection Statements

“if” Statements Without an “else” Block

Let’s say we want to extend the greet() method’s functionality to accept a name parameter and greet a user by name. We can update the greet() method as follow:

@StringBuilder func greet(name: String) -> String {
    "Hello"
    "World"

    if !name.isEmpty {
        "to"
        name
    }
}

print(greet(name: "Swift Senpai")) 
// Expected output: "Hello⭐️World⭐️to⭐️Swift Senpai"

With the above changes, you should see that the compiler started to complain about:

Closure containing control flow statement cannot be used with result builder 'StringBuilder'

This is because our StringBuilder currently does not understand what is if statements. In order to enable support for if statements that do not have an else, we must add the following result-building method to our StringBuilder.

@resultBuilder
struct StringBuilder {
    
    // ...
    // ...
    
    static func buildOptional(_ component: String?) -> String {
        return component ?? ""
    }
}

How it works is that when the if statement condition is met, the partial result will be passed to the buildOptional(_:) method, else nil will be passed to the buildOptional(_:) method.

To give you a clearer picture of how the result builder resolves each partial component under the hood, the greet(name:) function above is equivalent to the following code snippet:

func greetTranslated(name: String) -> String {

    // Resolve all partial components within the `if` block
    var partialComponent1: String?
    if !name.isEmpty {
        partialComponent1 = StringBuilder.buildBlock("to", name)
    }

    // Resolve the entire `if` block
    let partialComponent2 = StringBuilder.buildOptional(partialComponent1)

    // Resolve all partial components in `StringBuilder`
    let finalOutput = StringBuilder.buildBlock("Hello", "World", partialComponent2)

    return finalOutput
}

print(greetTranslated(name: "Swift Senpai")) 
// Output: "Hello⭐️World⭐️to⭐️Swift Senpai"

Notice how the result builder resolves whatever inside the if block first, and then propagates and resolves the partial component recursively until it gets the final output. This behavior is very important because it demonstrates fundamentally how the result builder resolves all the components within the components area.

Pro Tip:

Adding the buildOptional(_:) method not only enables support for if statements that do not have an else block, it also enables support for optional binding.

At this point, if you try calling the greet(name:) function with an empty name, you will get the following output:

print(greet(name: ""))
// Actual output: Hello⭐️World⭐️
// Expected output: Hello⭐️World

The extra "⭐️" at the end of the output string is due to the buildBlock(_:) method joining the empty string return by the buildOptional(_:) method.

To fix that, we can simply update the buildBlock(_:) method to filter out all the empty string from components before joining:

static func buildBlock(_ components: String...) -> String {
    let filtered = components.filter { $0 != "" }
    return filtered.joined(separator: "⭐️")
}

“if” Statements With an “else” Block

Our StringBuilder is now smarter than before, but saying "Hello⭐️World⭐️to⭐️Swift Senpai" sounds weird.

Let’s make it even smarter so that it will output "Hello⭐️to⭐️[name]" when name is not empty, else it will output "Hello⭐️World".

Go ahead and update the greet(name:) function as follow:

@StringBuilder func greet(name: String) -> String {
    "Hello"

    if !name.isEmpty {
        "to"
        name
    } else {
        "World"
    }
}

print(greet(name: "Swift Senpai"))
// Expected output: "Hello⭐️to⭐️Swift Senpai"

Once again, you will see the compile error:

Closure containing control flow statement cannot be used with result builder 'StringBuilder'

This time, because of the extra else block, we will have to implement another 2 result-building methods:

static func buildEither(first component: String) -> String {
    return component
}

static func buildEither(second component: String) -> String {
    return component
}

These 2 methods always come together. The buildEither(first:) method will trigger when the if block condition is met; whereas the buildEither(second:) method will trigger when the else block condition is met.

Here’s an equivalent function to helps you understand the logic happening behind the scene:

func greetTranslated(name: String) -> String {

    var partialComponent2: String!
    if !name.isEmpty {

        // Resolve all partial components within the `if` block
        let partialComponent1 = StringBuilder.buildBlock("to", name)
        // Resolve the entire `if-else` block
        partialComponent2 = StringBuilder.buildEither(first: partialComponent1)

    } else {

        // Resolve all partial components within the `else` block
        let partialComponent1 = StringBuilder.buildBlock("World")
        // Resolve the entire `if-else` block
        partialComponent2 = StringBuilder.buildEither(second: partialComponent1)
    }

    // Resolve all partial components in `StringBuilder`
    let finalOutput = StringBuilder.buildBlock("Hello", partialComponent2)

    return finalOutput
}

print(greetTranslated(name: "Swift Senpai"))
// Output: "Hello⭐️to⭐️Swift Senpai"

The “for-in” Loops

Next up, let’s update our greet(name:) function to do a countdown before greeting the user, because why not? 🤷🏻‍♂️

Go ahead and update the greet(name:) function as follow:

@StringBuilder func greet(name: String, countdown: Int) -> String {

    for i in (0...countdown).reversed() {
        "\(i)"
    }

    "Hello"

    if !name.isEmpty {
        "to"
        name
    } else {
        "World"
    }
}

print(greet(name: "Swift Senpai", countdown: 5))
// Expected output: 543210⭐️Hello⭐️to⭐️Swift Senpai

Notice I have added a countdown parameter to the function as well as a for loop at the beginning of the function. The for loop will perform a countdown from the given countdown value to 0.

The next and final thing to do is to update our StringBuilder with the following result-building method:

static func buildArray(_ components: [String]) -> String {
    return components.joined(separator: "")
}

Note that the buildArray(_:)method is a little bit different from the rest of the result-building method, it takes an array as an input.

What happens behind the scene is that at the end of each iteration, the for loop will produce a string (partial component). After going through all the iterations, the results from each iteration will be grouped as an array and pass it to the buildArray(_:)method.

To better illustrate the flow, here’s the equivalent function:

func greetTranslated(name: String, countdown: Int) -> String {

    // Resolve partial components in each iteration
    var partialComponents = [String]()
    for i in (0...countdown).reversed() {
        let component = StringBuilder.buildBlock("\(i)")
        partialComponents.append(component)
    }

    // Resolve the entire `for-in` loop
    let loopComponent = StringBuilder.buildArray(partialComponents)


    // `if-else` block processing here
    // ...
    // ...
    // ...


    // Resolve all partial components in `StringBuilder`
    let finalOutput = StringBuilder.buildBlock(loopComponent, "Hello", partialComponent2)

    return finalOutput
}

print(greetTranslated(name: "Swift Senpai", countdown: 5))
// Output: 543210⭐️Hello⭐️to⭐️Swift Senpai

With that, we have made our StringBuilder able to process the for-in loops. Now try to run the code, you should see "543210⭐️Hello⭐️to⭐️Swift Senpai" being printed at the Xcode console.

Note:

Adding the buildArray(_:)method will not enable support for while loops. In fact, for-in loops are the only looping method supported by the result builders.


Supporting Different Data Types

At this stage, we have made our StringBuilder pretty flexible, it can now accept selection statements, for loops, and optional bindings as input. However, there is one big limitation — it can only support String as input and output data types.

Fortunately, enabling support for various input and output data types is pretty straightforward. Let me show you how.

Enabling Various Input Data Types

Let’s say we want to make our StringBuilder support Int as input type, we can add the following result-building method to our StringBuilder:

static func buildExpression(_ expression: Int) -> String {
    return "\(expression)"
}

This buildExpression(_:)method is optional, it takes an integer as input and returns a string. Once it is implemented, it will become the entry point of the result builder and act as an adapter that converts its input data type to the data type accepted by the buildBlock(_:)method.

This is why you will see multiple “Cannot convert value of type ‘String’ to expected argument type ‘Int’” errors appearing after we added the buildExpression(_:) method, our StringBuilder is now no longer accept String as input data type, instead, it accepts Int as input data type.

Luckily for us, we can implement multiple buildExpression(_:) methods in our StringBuilder to make it accept both String and Int input data types. Go ahead and add in the following implementation, it should make all the errors disappear.

static func buildExpression(_ expression: String) -> String {
    return expression
}

With both methods in place, we can now change the greet(name:countdown:) function’s for loop as shown below and everything will still work accordingly.

@StringBuilder func greet(name: String, countdown: Int) -> String {

    for i in (0...countdown).reversed() {
        // Input an integer instead of a string here.
        i
    }

    // ...
    // ...

}

print(greet(name: "Swift Senpai", countdown: 5))
// Output: 543210⭐️Hello⭐️to⭐️Swift Senpai

Enabling Various Output Data Types

Adding support for various output data types is also pretty easy. It works similarly to supporting various input data types, but this time we will have to implement the buildFinalResult(_:) method that adds an extra layer of processing right before the final output.

For demonstration purposes, let’s make our StringBuilder able to output an integer representing the final output string character count.

static func buildFinalResult(_ component: String) -> Int {
    return component.count
}

Make sure to implement the following final result method as well so that our StringBuilder doesn’t lose the ability to output a string.

static func buildFinalResult(_ component: String) -> String {
    return component
}

To see everything in action, we can create a StringBuilder variable of Int type:

@StringBuilder var greetCharCount: Int {
    "Hello"
    "World"
}

print(greetCharCount)
// Output: 11 (because "Hello⭐️World" has 11 characters)

The Result Builders Use Cases

For the sake of demonstration, we have created a pretty useless string builder using result builders. If you would like to see some practical use cases of results builder, I highly recommend you to check out another article of mine — How I Created a DSL for Diffable Section Snapshot using Result Builders, and this article by Antoine van der LeeResult builders in Swift explained with code examples.

Furthermore, you can also check out this great GitHub repo that contains tons of projects built using result builders: awesome-function-builders.


Wrapping Up

I hope this article gives you a very good idea of how a result builder works under the hood. If you still have doubts about the fundamental concept of result builders, you can get the full sample code here and test it out yourself.

Do you have questions, feedback or comments? Feel free to leave it at the comment section below, or you can reach out to me on Twitter.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.