I am happy to announce that the result builder implementation has been re-worked in Swift 5.8 to greatly improve compile-time performance, code completion results, and diagnostics. The new implementation is now enabled by default on main
and release/5.8
. The Swift 5.8 result builder implementation enforces stricter type inference that matches the semantics in SE-0289: Result Builders, which has an impact on some existing code that relied on invalid type inference. This post outlines the motivation and the impact of the new result builder implementation.
The original result builder transform implementation has some significant problems - type-checking becomes slower as the number of elements in the transformed body grows, code completion is not always helpful or fast, and invalid result builders often produce poor diagnostics, especially the unhelpful âfailed to produce diagnosticâ fallback message. All of these problems stem from a bespoke type inference mechanism for result builders that did not match the simple source-level transformation in the result builder design.
The new implementation takes advantage of the extended multi-statement closure inference introduced in Swift 5.7 and applies the result builder transformation exactly as specified by the result builder proposal - a source-level transformation which is type-checked like a multi-statement closure. Doing so enables the compiler to take advantage of all the benefits of the improved closure inference for result builder transformed code, including optimized type-checking performance (especially in invalid code) and improved error messages. Under this model, each element in a result builder body is type-checked in isolation, allowing code completion to skip unrelated elements to yield improvements in its accuracy and performance.
In the vast majority of cases, there is no visible difference between the original and new result builder implementation. However, some result builder uses that compile in Swift 5.7 relied on invalid type inference that was not part of the intended semantics described by the result builder proposal. Such result builder uses were also the most prone to poor type checking performance, diagnostics, and code completion results. While this invalid type inference is now rejected in Swift 5.8, there are several strategies that can be used to achieve the same effect in result builders that forward-propagate type information or provide additional type context for its components, which are covered later in this post.
Improvements
Letâs take a look at couple of examples improved in Swift 5.8:
- Improved diagnostics when contextual base type couldnât be resolved:
import SwiftUI
struct ContentView: View {
enum Destination {
case one
case two
}
var body: some View {
List {
NavigationLink(value: .one) {
Text("one")
}
NavigationLink(value: .two) {
Text("two")
}
}
.navigationDestination(for: Destination.self) { $0.view }
}
}
Swift 5.7 produced a misleading error:
error: value of type 'ContentView.Destination' has no member 'view'
.navigationDestination(for: Destination.self) { $0.view }
~~ ^~~~
Swift 5.8 produces the following error that precisely points out the mistake:
error: cannot infer contextual base in reference to member 'one'
NavigationLink(value: .one) {
~^~~
- Time to type-check this relatively simple example of Table API use dropped from 4s. to 0.6s.
import SwiftUI
import Foundation
struct Data: Identifiable {
var value: Int
let id: UUID = UUID()
init(value: Int) {
self.value = value
}
}
struct MyTest : View {
var body: some View {
var comparators = [KeyPathComparator(\Data.value)]
Table([Data(value: 0)], sortOrder: .constant(comparators)) {
TableColumn("String 1", value: \.id.uuidString)
TableColumn("String 2", value: \Data.id.uuidString)
TableColumn("String 3", value: \Data.value) {
Text("\($0.value)")
}
Group {
TableColumn("String 3", value: \Data.id.uuidString)
TableColumn("View") {
Text("\($0.value)")
}
}
}
}
}
- Invalid code has even bigger performance gains:
import SwiftUI
import Foundation
struct Place: Identifiable, Hashable {
let id: UUID = UUID()
let name: String
let comfortLevel: String
let noiseLevel: Int
}
struct PlacesView : View {
@State var places: [Place]
var body: some View {
NavigationStack {
Table(places) {
TableColumn("Name", value: \.name)
TableColumn("Comfort Level", value: \.comfortLevel).width(200)
TableColumn("Noise", value: \.noiseLevel)
}
.navigationTitle("All Places")
}
}
}
This example would produce error: the compiler is unable to type-check this expression in reasonable time
error when compiled with Swift 5.7. Swift 5.8 is capable of quickly identifying a mismatch in the last TableColumn
argument:
error: key path value type 'Int' cannot be converted to contextual type 'String'
TableColumn("Noise", value: \.noiseLevel)
^
- Structural diagnostics:
import SwiftUI
struct Item : Hashable {
var question: String
var answer: Int
}
struct MyView : View {
var items: [Item] = []
var body: some View {
ZStack {
ForEach(items, id: \.self) { item in
if let question = item.question { đ´ initializer for conditional binding must have Optional type, not 'String'
...
}
}
}
}
}
- Pattern matching diagnostics as described in Another âFailed to produce diagnostic for expressionâ error with basic switch mismatched type ¡ Issue #61106 ¡ apple/swift ¡ GitHub
Strategies for propagating type information through result builders
Type inference failures can always be resolved with explicit type annotations, but there are a few techniques that result builders can use to achieve better call-site ergonomics when the result builder can provide the necessary context. Result builders have the ability to specify additional type context for components at the use site using buildExpression
, and generic result builders can be used for forward propagation of type information. Letâs take a look at a few examples.
- Result builders that rely on
build{Partial}Block
to provide context to the elements in the body:
@resultBuilder
struct TupleBuilder {
...
static func buildBlock(_ v1: String,
_ v2: String) -> (String, String) {
(v1, v2)
}
}
func ambiguous() -> Int { 42 }
func ambiguous() -> String { "World" }
func test<T>(@TupleBuilder _ fn: () -> T) {}
test {
"Hello"
ambiguous()
}
To better understand why this is a problem, letâs take a look at the transformed body:
test {
var v1 = "Hello"
var v2 = ambiguous()
return TupleBuilder.buildBlock(v1, v2)
}
The result builder proposal states that initialization expressions for v1
, v2
are type-checked independently. Not allowing type information to back-propagate or side-propagate between statements is deliberate in the result builder design, because the underlying type inference model follows that of regular function bodies, and it prevents new avenues for exponential type-checking complexity. However, in the above example, independent type checking of components makes it impossible for the compiler to determine which overload of the function ambiguous
to choose.
In order to make this work TupleBuilder
needs to implement buildExpression
that would provide the necessary context:
extension TupleBuilder {
static func buildExpression(_ v: String) -> String { v }
}
Because buildExpression
wraps every initialization expression, itâs now possible to filter out unrelated overload of ambiguous
:
test {
var v1 = TupleBuilder.buildExpression("Hello")
var v2 = TupleBuilder.buildExpression(ambiguous())
return TupleBuilder.buildBlock(v1, v2)
}
This is a very simple example, but this technique applies to more complex cases as well - i.e. buildExpression
could be used to make sure that all statements conform to a certain protocol or a set thereof and share other common properties that could be expressed via generic requirements. buildExpression
can also be overloaded to provide a set of possible type contexts that will be resolved based on the argument during overload resolution.
- Bi-directional inference between result builder components:
In certain cases the original implementation behaved as-if all the elements in the body have been type-checked together:
protocol CaseValue {
associatedtype B
associatedtype V
}
protocol ContextualValue {
associatedtype Context
associatedtype V
}
struct Case<Base, Value> : CaseValue {
typealias B = Base
typealias V = Value
init<T>(value: Value,
@ValueBuilder<Base> _ value: () -> T) {}
}
@resultBuilder
struct Switch<Base, Value> {
init(_ keyPath: KeyPath<Base, Value>,
@CaseBuilder<Base, Value> _ cases: () -> [Case]) {}
}
@resultBuilder
struct CaseBuilder<Base, Value> {
static func buildExpression<T: CaseValue>(_ v: T) -> T
**where** **T****.****B** **==** **** **Base****,** **T****.****V** **==** **** **Value** { v }
...
}
@resultBuilder
struct ValueBuilder<Context> {
...
static func buildBlock<T: ContextualValue>(_ v: T) -> T.V
**where T.Context == Context** { ... }
}
struct Test {
var x: Int
struct Value<T> : ContextualValue {
typealias Context = Test
typealias V = T
init(_: T) {}
}
func test() {
Switch(\.x) {
Case(.zero) {
Value("0")
}
}
}
}
This is a convoluted example but there is no way to illustrate it fully without using multiple builders. Letâs take a look at the transformed version of the body of test()
:
func test() {
Switch(\.x) {
var v0 = CaseBuilder.buildExpression(Case(.zero) {
var v1 = Value("0")
return ValueBuilder.buildBlock(v1)
})
return CaseBuilder.buildBlock(v0)
}
}
To fully resolve \.x
passed to initializer of Switch
, the compiler has to fully type-check v0
which is in turn impossible because Value
associated with CaseBuilder
could only be resolved after \.x
is type-checked.
- Result builder types with generic parameters
Because result builders are designed prohibit side-propagation of type information between their components, generic result builders must be fully resolved at the first call to a result builder method such as buildExpression
or buildBlock
. However, under some circumstances, the original result builder implementation allowed such generic parameters to be inferred as-if all of the statements were type-checked together, leading to worst-case type checking performance in the compiler. The new implementation enforces forward-propagation and allows generic parameters associated with a result builder type to be inferred only from the enclosing context and/or from the first buildExpression
in the body:
protocol MyProtocol {
associatedtype Value
}
struct Data<Value> : MyProtocol {
init(_ v: Value) {}
}
@resultBuilder
struct GenericBuilder<T> {
static func buildExpression<U: MyProtocol>(_ v: U) -> U
where U.Value == T { v }
}
func test<T>(@GenericBuilder<T> _ fn: () -> T) {}
test {
Data(0)
Data(1)
Data(2)
}
The transformed body looks like this:
test {
var v1 = GenericBuilder.buildExpression(Data(0))
var v2 = GenericBuilder.buildExpression(Data(1))
var v3 = GenericBuilder.buildExpression(Data(2))
return GenericBuilder.buildBlock(v1, v2, v3)
}
buildExpression
connects its generic parameter U
to the contextual generic parameter T
which allows to infer it from the first statement in the body and make sure that all of the other statements have the same type.
- Partially resolved closure parameters used in the result builder body
@resultBuilder
struct MyBuilder { ... }
struct Test<Content> {
enum State {
case stateA
case stateB
}
init(@MyBuilder fn: (State) -> Content) { ... }
}
Test { state in
if state == .stateA {
...
}
}
This is an attempt to reference a member on a partially resolved type Test<Content>
, the reference is ill-formed because Content
is inferred from the buildBlock
that represents the return of the closure body, which means expression state == .stateA
cannot produce a complete solution and would be rejected by the compiler.
The workaround here could be to lift State
from Test
to top-level scope because it doesnât rely on the Content
, in more complex situations it might be possible to connect Content
of Test
with MyBuilder
by making it generic and use technic outlined in the first bullet point to infer it early.