October 26, 2022·5 min read·engineeringswiftresult-buildersevolution

Result builders gain ordinary local variable declarations

SE-0373 removes implementation restrictions on local variables in result builders. Builders still control whether assignment statements can contribute Void results.

A local variable inside a result builder looks like an ordinary Swift declaration, but the implementation has imposed extra restrictions on it. It needs a simple stored name and an explicit initializer. A computed local or a declaration using a property wrapper encounters restrictions that do not follow from the original description of the builder transformation. SE-0373 brings those declarations back under ordinary language rules.

The October 26 acceptance follows discussion about one particularly consequential case, initializing a variable after declaring it. The Language Workgroup kept the review open to consider that feedback. Its decision preserves a builder's ability to reject assignment statements even while broadening the declarations the compiler can transform.

I find Swift easier to reason about when familiar syntax follows familiar rules. Builder-specific restrictions on a local declaration add another language detail to remember. Removing those restrictions is useful, provided the builder still has a clear way to specify which expressions belong in its result.

A transformation should not redefine every declaration

Pavel Yaskevich's SE-0373 proposal identifies a discrepancy between the result builder specification and its implementation. The specification says local declarations are unaffected by transformation. The implementation nevertheless rejects several forms permitted in an ordinary function.

The accepted direction treats these local declarations as ordinary declarations. It covers computed variables, observers and property wrappers, as well as lazy variables and default initialization. Standard semantic checks remain in force. The compiler does not accept an otherwise invalid local merely because the surrounding function uses a builder.

Destructuring illustrates the difference without requiring a complicated API. A declaration that binds the elements of a returned tuple does not introduce a new kind of builder expression. It defines names that later source can use. Requiring it to have only one simple name was an implementation restriction on local declarations, rather than an inherent requirement for combining the builder's results.

Property wrappers provide another motivation. The proposal describes wrapping a local value for validation or formatting, including a width derived from a geometry context. Such a declaration can support the construction of a result without itself being one of the result's components. Keeping declaration semantics separate from expression transformation allows that use to follow the normal local-variable model.

An assignment is also an expression statement

The original SE-0289 result builder design describes how expression statements become partial results. A builder can supply buildExpression to translate an expression into the component type its other methods combine. Ordinary overload resolution applies when checking those generated calls.

An assignment statement participates in that transformation and produces (), also called Void. A builder can choose to accept that result type. It can also omit support for it, making an assignment invalid in the transformed body.

The following method shape, adapted from SE-0289, describes the relevant choice. Component represents the builder's own partial-result type, and the implementation would supply an appropriate value.

// Signature a builder can provide to handle assignment results.
static func buildExpression(_: ()) -> Component

This is an API sketch, not a standalone function to paste into an application. Its significance is that accepting Void is a property of the builder's expression handling. The language does not need a separate category of assignment syntax to let each builder make that choice.

SE-0373 retains that rule for initializing assignments. Declaring an uninitialized variable does not make the later assignment disappear from the builder transformation. That later statement must still have a valid representation in the builder's result computation.

Declarative builders can retain their restrictions

John McCall's acceptance explanation describes why this matters for declarative APIs. Some builders intentionally discourage side effects in their bodies. SwiftUI is the example in the decision. A body may be reevaluated, so an effect inside it can happen again. A state change can also cause another evaluation and repeat the same effect.

Assignments can encourage a procedural interpretation of a body even when the immediate purpose is only to initialize a local. The workgroup therefore considered whether broadening variable declarations would accidentally force declarative builders to accept them. Its conclusion was that the existing Void rule already supplies the needed distinction.

A builder that rejects Void results continues to reject initializing assignments, just as it rejects other assignments. The acceptance explicitly names SwiftUI's ViewBuilder as retaining that behaviour. Allowing more local declaration forms does not mean that every builder body becomes an unrestricted imperative block.

This also explains why a declaration can be permitted in principle while one use of it remains invalid for a particular builder. The declaration follows ordinary Swift rules. The expression statement required to initialize it later follows the builder's rules for expressions. Both constraints have to hold for the whole body to type-check.

I consider that division more understandable than an unexplained list of forbidden local forms. The builder controls the kinds of results it can combine. The language controls the validity of local declarations. An assignment crosses both concerns because it changes storage and also appears as a statement in the transformed body.

Acceptance does not change the current compiler

Today's event is acceptance of the proposal, rather than an announcement of a stable compiler release containing it. The proposal describes an additive change with no new syntax. It also states that the change needs no new runtime or standard library support and does not alter the transformed function's external interface.

Those properties explain the scope of the implementation work. The compiler must permit additional local declarations and apply the usual semantic checks to them. A builder author still determines whether its expression handling accepts Void, either explicitly or through the component combination methods. That existing decision remains the reason an initializing assignment can work in one builder and fail in another.

Default initialization is another reason to avoid equating every declaration with a separate assignment statement. SE-0373 explicitly includes optional locals that receive default initialization. Their ordinary initialization behaviour need not be rewritten as a user-authored assignment within the builder. The proposal preserves that difference while removing the previous blanket requirement for an explicit initializer expression.