WWDC Quick Look 💓 By SwiftGGTeam
Write a DSL in Swift using result builders

Write a DSL in Swift using result builders

Watch original video

Highlight

Swift 5.4’s Result Builders allow developers to implement embedded domain-specific languages ​​using Swift syntax, collecting multiple declarations in closures into arrays, view trees, or custom data structures to reduce boilerplate code and maintenance errors.

Core Content

You maintain a recipe app. Each smoothie has an id, title, description, ingredients, and paid status. The most straightforward way to write it is to create a static constant for each recipe and maintain a total array.

Problems quickly emerged.

When adding a new recipe, the developer may only create constants but forget to include them in the total array. It’s also possible to add the same recipe twice. The line of ingredients is still very long.MeasuredIngredientMeasurementmeasurementThese words appear over and over again, and the really important message is just “1.5 cups of oranges.”

Fruta’s recipes are also updated by designers, marketing colleagues and managers. The more the code looks like an initializer argument list, the easier it is for non-engineers to correct it.

In this session, Apple used Fruta to demonstrate a solution: writing an embedded DSL (domain specific language) using Result Builders. DSL is still Swift. It uses trailing closure,@resultBuilderAnd the chained modifier method hides “how to assemble the array” and allows the maintainer to only write the recipe itself.

The ultimate goal is to write the recipe list like this: smoothie is listed directly in the function body; when paid content is needed, use ordinaryif;In the closure of each smoothie, write the description first, and then the ingredients. The Swift compiler is responsible for passing these statements to the builder’s static methods.

Detailed Content

What Result Builder does: collect expressions in closures

(09:26) SwiftUIVStackis the most familiar example. You write multiple in the closureText, without explicitly returning an array, and without manually callingaddSubview

VStack {
    Text("Title").font(.title)
    Text("Contents")
}

Key points:

  • VStackReceives a trailing closure. -Text("Title").font(.title)Create the first view and apply the modifier in the same statement. -Text("Contents")Create a second view.
  • The statements inside the closure will beViewBuildercollect, formVStackcontent.

09:50ViewBuilderThe core form is as follows.

@resultBuilder enum ViewBuilder {
    static func buildBlock(_: View...) -> some View {  }
}

Key points:

  • @resultBuilderTells the compiler that this type can be used as a result builder. -buildBlockReceives multiple values ​​produced in the closure.
  • The return value is the final result of the closure given to the caller.
  • hereFrom the session code snippet, indicating that SwiftUI internal implementation is omitted.

Result Builder is a compile-time feature. It does not require a new system version. Swift 5.4 brings the final version into the language, corresponding to Xcode 12.5.

First turn multiple smoothie statements into arrays

(24:05) The first step in Fruta is to define aSmoothieArrayBuilder. It puts multiple in the function bodySmoothiecollected into[Smoothie]

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
    return components
  }
}

Key points:

  • enum SmoothieArrayBuilderThere is no case, it is only used as a static method container. -buildBlockThe parameters areSmoothie..., can receive any number of smoothies. -return componentsReturn the variable-length parameters as an array.
  • This builder is suitable for the simplest lists: only one line in the function bodySmoothie

(24:39) The compiler will convert the function body into a form similar to the following.

// How ‘buildBlock(…)’ works

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) {
    /* let v0 = */ Smoothie(id: "berry-blue", title: "Berry Blue") {  }

    /* let v1 = */ Smoothie(id: "carrot-chops", title: "Carrot Chops") {  }

    // …more smoothies…

    /* return SmoothieArrayBuilder.buildBlock(v0, v1, …) */
}

Key points:

  • @SmoothieArrayBuilderMarked on the function, the function body uses this builder.
  • eachSmoothie(...)The expression is saved to a temporary value first.
  • last callSmoothieArrayBuilder.buildBlock
  • buildBlockThe return value becomes the function body result.

supportifbuildOptionalbuildBlockandbuildExpression

(27:47) Fruta needs to be based onincludingPaidDecide whether to include paid recipes. OrdinaryifRequires builder implementationbuildOptional

Writing this way wasn’t enough at first.

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
    return components
  }
}

Key points:

  • buildOptionalProcessing noelseofif.
  • When the conditions are met,componentyesifArray collected in vivo.
  • When the condition is not met, Swift passes innil.
  • This version will encounter type problems becauseifThe result is[Smoothie], oldbuildBlockStill looking forward to a singleSmoothie

(31:44) The correct version has been addedbuildExpression. It converts a single word in an ordinary statement intoSmoothiealso converted to[Smoothie]

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: [Smoothie]...) -> [Smoothie] {
    return components.flatMap { $0 }
  }

  static func buildExpression(_ expression: Smoothie) -> [Smoothie] {
    return [expression]
  }
}

Key points:

  • buildExpression(_ expression: Smoothie)First make each nakedSmoothieWrapped into a single-element array. -buildOptionalAlso returned[Smoothie]
  • buildBlockReceive multiple[Smoothie]
  • flatMap { $0 }Flatten multiple arrays into one array.

This step solves the first real maintenance problem in the DSL: you can use Swift’sif, no additional maintenance requiredhasFreeRecipeand filtering logic.

supportif-elseand log statements

(32:48) withelseThe conditional branch requiresbuildEither(first:)andbuildEither(second:). Fruta’selsewill also be calledlogger.log(...), this expression returnsVoid, so builder must also acceptVoid

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildEither(first component: [Smoothie]) -> [Smoothie] {
    return component
  }

  static func buildEither(second component: [Smoothie]) -> [Smoothie] {
    return component
  }

  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: [Smoothie]...) -> [Smoothie] {
    return components.flatMap { $0 }
  }

  static func buildExpression(_ expression: Smoothie) -> [Smoothie] {
    return [expression]
  }

  static func buildExpression(_ expression: Void) -> [Smoothie] {
    return []
  }
}

Key points:

  • buildEither(first:)take overifBranch results. -buildEither(second:)take overelseBranch results.
  • Fruta doesn’t care which branch is taken, so both methods return parameters directly. -buildExpression(_ expression: Void)Turn the log statement into an empty array.
  • Empty arrays will bebuildBlockMerged, no elements will be added to the smoothie list.

(32:53) The compilerif-elseThe conversion method can be understood as this.

// How ‘if’-‘else’ statements work with ‘buildEither(…)’

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    /* let v0: [Smoothie] */
    if includingPaid {
        /* let v0_0 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {  } /* ) */
        /* let v0_block = SmoothieArrayBuilder.buildBlock(v0_0)
           v0 = SmoothieArrayBuilder.buildEither(first: v0_block) */
    }
    else {
        /* let v0_0 = SmoothieArrayBuilder.buildExpression( */ logger.log("Only got free smoothies!") /* ) */
        /* let v0_block = SmoothieArrayBuilder.buildBlock(v0_0)
           v0 = SmoothieArrayBuilder.buildEither(second: v0_block) */
    }

    /* return SmoothieArrayBuilder.buildBlock(v0) */
}

Key points:

  • includingPaidStill normal Swift conditions. -ifin the branchSmoothiepass firstbuildExpression.
  • Call again within the branchbuildBlock. -For branch resultsbuildEitherPackage.
  • Call the outermost layer againbuildBlockMerge final results.

Use modifier method to compress ingredients writing method

(36:41) Too much boilerplate code for the ingredients line. Apple uses two modifier style methods to split “unit” and “multiple” into chain calls.

extension Ingredient {
  func measured(with unit: UnitVolume) -> MeasuredIngredient {
    MeasuredIngredient(self, measurement: Measurement(value: 1, unit: unit))
  }
}

extension MeasuredIngredient {
  func scaled(by scale: Double) -> MeasuredIngredient {
    return MeasuredIngredient(ingredient, measurement: measurement * scale)
  }
}

Key points:

  • measured(with:)defined inIngredientsuperior.
  • it uses the incomingUnitVolumeCreate a quantity of 1MeasuredIngredient
  • scaled(by:)defined inMeasuredIngredientsuperior.
  • it returns a newMeasuredIngredient, the measurement is multiplied by the input magnification. -Ingredient.orange.measured(with: .cups).scaled(by: 1.5)Keep only the information the recipe maintainer cares about: oranges, cups, 1.5.

This design has an added benefit. Fruta’s recipe page would have passed the ratio to each row of ingredient views.scaled(by:)After it appears, you can scale the ingredients themselves first and then hand them over to the row view, with less code in the view layer.

Add a builder to each smoothie closure

37:32SmoothieArrayBuilderOnly works onallfunction body.Smoothie(...) { ... }The trailing closure inside is another function body, and the outer builder will not automatically affect it.

// Closures and result builders

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    /* let v0 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {
        "Filling and refreshing, this smoothie will fill you with joy!"

        Ingredient.orange.measured(with: .cups).scaled(by: 1.5)
        Ingredient.blueberry.measured(with: .cups)
        Ingredient.avocado.measured(with: .cups).scaled(by: 0.2)
    } /* ) */

    /* let v1 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {
        "Packed with vitamin A and C, Carrot Chops is a great way to start your day!"

        Ingredient.orange.measured(with: .cups).scaled(by: 1.5)
        Ingredient.carrot.measured(with: .cups).scaled(by: 0.5)
        Ingredient.mango.measured(with: .cups).scaled(by: 0.5)
    } /* ) */

    /* return SmoothieArrayBuilder.buildBlock(v0, v1) */
}

Key points:

  • Outer layer@SmoothieArrayBuildercollectSmoothie(...)these expressions. -SmoothieThe trailing closure is required to return the description and ingredients.
  • The outer builder will not enter this trailing closure.
  • need to be inSmoothieMark the new builder on the closure parameter of the initializer.

(39:22) The new builder is calledSmoothieBuilder. It requires that the first statement is a description string, followed by any number ofMeasuredIngredient

extension Smoothie {
  init(id: Smoothie.ID, title: String, @SmoothieBuilder _ makeIngredients: () -> (String, [MeasuredIngredient])) {
    let (description, ingredients) = makeIngredients()
    self.init(id: id, title: title, description: description, measuredIngredients: ingredients)
  }
}

@resultBuilder
enum SmoothieBuilder {
  static func buildBlock(_ description: String, components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    return (description, components)
  }
}

Key points:

  • @SmoothieBuilderWritten before the closure parameters. -makeIngredientsReturn after execution(String, [MeasuredIngredient]).
  • The initializer splits the tuple intodescriptionandingredients.
  • The last line calls the existing initializer and passes indescriptionandmeasuredIngredients
  • SmoothieBuilder.buildBlockThe first parameter of isString, so the first line of the closure must be a description. -components: MeasuredIngredient...Collect subsequent ingredients.

Write special overloads for errors

(44:55) If the maintainer forgets to write a description, Swift will report “The first ingredient cannot be converted to String” by default. This error is accurate, but not straightforward enough for DSL users.

Apple’s approach is to let the builder match this incorrect writing, and then use@available(*, unavailable, message:)Give specialized information.

extension Smoothie {
  init(id: Smoothie.ID, title: String, @SmoothieBuilder _ makeIngredients: () -> (String, [MeasuredIngredient])) {
    let (description, ingredients) = makeIngredients()
    self.init(id: id, title: title, description: description, measuredIngredients: ingredients)
  }
}

@resultBuilder
enum SmoothieBuilder {
  static func buildBlock(_ description: String, components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    return (description, components)
  }

  @available(*, unavailable, message: "first statement of SmoothieBuilder must be its description String")
  static func buildBlock(_ components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    fatalError()
  }
}

Key points:

  • firstbuildBlockIs a legal path: description string plus ingredients.
  • the secondbuildBlockWrong path matching “only ingredients, no description”. -@available(*, unavailable, message:)Make this path cause an error when compiling. -fatalError()Just letting the function body type check pass, this method will not run successfully.
  • The error message directly states the DSL rule: the first statement must be a description string.

Core Takeaways

  • What to do: Write a DSL to the App’s routing table. Why it’s worth doing: The session mentioned that the server framework allows users to declare URLs and handlers, and then the framework registers them. Result Builder is suitable for collecting a set of statements. How ​​to get started: DefinitionRouteType, then write@resultBuilder enum RouteBuilder,usebuildBlock(_ components: Route...) -> [Route]Collect routes.

  • What to do: Change complex form definitions into declarative lists. Why it’s worth doing: Form fields are often tweaked by product or operations. The DSL can reduce array brackets, commas, and repeated initializer parameters. How ​​to get started: forFormFieldBuild builder, usebuildOptionalSupports displaying fields according to user roles, and uses the modifier method to describe verification rules.

  • What to do: Write Swift DSL for game levels or NPC dialogue. Why it’s worth doing: session points out that room maps and NPC dialogues in text adventure games are often maintained by designers and writers. A DSL allows them to focus only on content structure. How ​​to get started: DefinitionRoomDialogLineChoice, use nested builders to collect room and conversation branches separately.

  • What to do: Write the function switch configuration into compilable and checked Swift code. Why it’s worth doing: Result Builder can incorporate ordinary Swift conditional statements into DSL to avoid manual splicing of temporary arrays. How ​​to get started: DefinitionFeatureFlag,accomplishbuildExpressionWrap a single flag into an array and then implementbuildEitherSupport environment branching.

  • What to do: Write preview data DSL for design system components. Why it’s worth doing: The sample data is repetitive, large in quantity, and the order often needs to be adjusted. The builder can collect multiple examples into an array, and the modifier method can compress common attributes. How ​​to get started: DefinitionComponentExample,use@ExampleBuilderCollect examples; add to examples.state(...).locale(...)This type of modifier.

Comments

GitHub Issues · utterances