March 30, 2023·6 min read·engineeringswiftcompilerresult-buildersmigration

Swift 5.8 makes result-builder inference follow its design

The result-builder rewrite constrains inference to improve diagnostics, while upcoming-feature flags let modules adopt future language rules incrementally.

A result-builder body can look like a sequence of simple expressions while asking the compiler to solve a much larger inference problem. When type information travels between those expressions in ways the design never intended, an error in one component can become a timeout or a misleading message elsewhere. Swift 5.8 addresses that problem by making the implementation follow the documented source transformation more closely.

The March 30 release announcement describes a reworked result-builder implementation with better compile-time performance, diagnostics, and completion. It also warns that stricter inference rejects some code accepted by Swift 5.7. That combination is the significant change. More predictable inference requires limits on how much context an expression can obtain from the rest of the body.

Build times are the part of Swift I find least pleasant. This change interests me because its explanation identifies an actual source of compiler complexity. The performance claim follows from a narrower inference model, with published examples that show where the difference appears.

A source transformation with local decisions

The compiler team's February explanation describes the old implementation as a bespoke inference mechanism. It could become slower as a builder body grew, and invalid bodies often produced poor diagnostics. The replacement uses the extended multi-statement closure inference introduced in Swift 5.7.

Under the intended transformation, each component becomes an initialization expression. The compiler checks those expressions independently before combining their values through builder methods. Information should not flow backward from the final combination or sideways from one component into another.

Consider the simplified case from the compiler explanation. A builder combines two strings, and the second expression calls an overloaded function that can return either an integer or a string. Knowing that the final combination wants strings seems sufficient to a human, but allowing that later requirement to determine an earlier expression would violate the intended inference boundary.

The issue becomes clearer when the transformation is written out. The compiler has to establish the types of intermediate values before it calls the combining method. An overloaded initializer with no local type context cannot rely on the later call to choose its result type.

func choice() -> Int { 42 }
func choice() -> String { "value" }

let first = "prefix"
let second: String = choice()

The explicit annotation in this ordinary Swift example selects the string overload at the initialization site. A result-builder author can supply comparable context through the builder's expression conversion. The repair belongs where the component is checked, because the final combination no longer supplies backward inference.

What buildExpression contributes

The compiler team's example resolves the overloaded call by adding a string-specific expression method. The following complete builder uses the same mechanism for two string components.

@resultBuilder
struct PairBuilder {
    static func buildExpression(_ value: String) -> String {
        value
    }

    static func buildBlock(
        _ first: String,
        _ second: String
    ) -> (String, String) {
        (first, second)
    }
}

func pair(@PairBuilder _ body: () -> (String, String))
    -> (String, String) {
    body()
}

func choice() -> Int { 42 }
func choice() -> String { "value" }

let result = pair {
    "prefix"
    choice()
}

Each component goes through PairBuilder.buildExpression. Its parameter requires a string, giving the overloaded call a local context in which to resolve. The builder's combination method still determines the final tuple, but it does not have to send a requirement backward into an otherwise ambiguous component.

This also explains why an annotation can fix an individual use without repairing the abstraction. Explicit annotations are available when the intended type is known at the call site. A builder that is designed to accept a particular family of values can often express that family in buildExpression, giving every caller the same contextual information.

Generic builders require similar discipline. The compiler explanation says their parameters must resolve from the enclosing context or the first relevant builder-method call. Later components cannot jointly determine a generic parameter as though the entire body were one inference problem. Forward propagation remains available, so a first component can establish a type that subsequent components must satisfy.

The restriction has consequences for API design. A builder should make the information needed for its early components available early. Depending on a later expression to disambiguate an earlier one makes the source concise at the expense of the compiler's intended checking model.

Faster failure can be more useful than faster success

The published examples include a SwiftUI table whose type-checking time falls from four seconds to 0.6 seconds. That is a result for the particular example in the compiler team's post. It is not a general speedup factor for Swift applications or for all uses of result builders.

The invalid examples are at least as instructive. One table supplies an integer-valued key path where the API expects a string. Swift 5.7 reports that it cannot type-check the expression in reasonable time. Swift 5.8 identifies the incompatible key-path value type at the relevant column.

A different example shows the danger of a diagnostic displaced from its cause. An unresolved contextual base for an enum case previously led to an error at a later modifier. The new implementation reports the missing contextual base at the case expression. The developer gets a location tied to the unresolved component.

Completion benefits from the same separation. Since the compiler checks elements independently, it can skip unrelated components when completing the expression under the cursor. The architectural change therefore affects editing as well as successful builds.

I prefer this kind of constraint when it makes a language rule easier to explain. A builder is already a transformation that readers must understand. Requiring its implementation to honor that transformation reduces the extra inference behavior an API author has to anticipate.

Compiler versions and language rules are separate

The other major thread in this release is incremental adoption of upcoming language changes. SE-0362, by Doug Gregor, distinguishes the compiler's tools version from its language mode. Installing a newer compiler does not mean that all source-compatibility changes should become active in an existing language mode.

Some accepted changes must wait for a new language mode because they alter existing source behavior or make existing code invalid. Waiting for the complete collection creates a larger migration task. The proposal provides a way to enable individual changes within an existing mode.

The command-line spelling is -enable-upcoming-feature, followed by a feature identifier. Swift 5.8's announcement lists ConciseMagicFile, ForwardTrailingClosures, ExistentialAny, and BareSlashRegexLiterals among the available choices. The selection is explicit, so a project opts into a named change rather than an evolving collection.

Swift Package Manager exposes the same choice at target level through SwiftSetting.enableUpcomingFeature. A target can include the following setting in its swiftSettings array in a Swift 5.8 manifest.

swiftSettings: [
    .enableUpcomingFeature("ExistentialAny")
]

SE-0362 explains that these settings do not have to propagate to dependent targets. Their effects stay within the module being compiled. That makes the module a practical unit for migration, since a library can adopt a rule without requiring every consumer to make the same source changes immediately.

Temporary flags with a defined end

Upcoming-feature flags are for accepted changes destined for a later language mode. The proposal explicitly rejects permanent optional language dialects. Once the selected language mode enables a feature by default, supplying its upcoming-feature flag is an error.

That end condition matters. A migration setting records work that can happen before the larger language-mode change, and the compiler later identifies when the setting has become redundant. It does not remain an indefinite toggle that future maintainers must interpret.

The proposal also rejects one flag that would enable every upcoming feature. Such a flag would change meaning as new features appeared, potentially breaking opted-in code with each compiler update. Naming individual features keeps the project's chosen behavior stable as the tools evolve.

Source can inspect enabled features with #if hasFeature(FeatureIdentifier). Supporting older compilers still requires care because compilers predating the directive do not understand it. SE-0362 discusses nested compiler-version checks for that compatibility problem. A feature test is therefore distinct from simply comparing a tools-version number, and conditional parsing remains part of a library's compatibility contract.

Conditional attributes address another part of maintaining code across toolchains. Swift 5.8 permits conditional-compilation checks around attributes on a declaration and adds hasAttribute to test availability in the current language mode. The release announcement demonstrates this with @preconcurrency on a protocol. Library authors can express that difference around the attribute without duplicating the declaration itself. This is a separate facility from enabling a language change for the entire target, since it controls which annotations appear in a particular source configuration.

Result-builder checking and upcoming-feature selection address different compatibility questions. Correcting inference brings the compiler into line with the original builder design. Feature selection provides controlled access to accepted source-affecting rules awaiting a new language mode. Swift 5.8 makes both boundaries more explicit, giving builder authors a defined inference model and package authors a per-target way to schedule language migration.