Reference

Write a builder

Write a function that creates a value, hands it to a receiver closure, and returns it - three lines that make every property command, nested block and method call in the closure statically typed.

A builder is a function, not a special declaration: it creates a value, hands it to a closure whose first parameter is self, and returns what the closure configured. Once the receiver type has fields and methods, the block that calls the builder reads like a small language of its own - checked like ordinary code, because it is ordinary code.

Steps#

  1. Declare the type the block will configure, with var on every field the block may set.

    type ServerOptions {
      var host: String = "localhost"
      var port: Int = 8080
    }
  2. Write the builder: create, configure, return. The parameter is a receiver closure over the type from step 1.

    fn serve(configure: (var self: ServerOptions) => Void): ServerOptions {
      var options = ServerOptions()
      configure options
      options
    }
  3. Call it with a trailing closure. A field inside is written with =; nothing else has to change for host = "0.0.0.0" to mean self.host = "0.0.0.0".

    const options = serve {
      host = "0.0.0.0"
      port = 8443
    }
  4. Give a field its own nested block instead of a second builder function, by making the field's type support the same mechanism. database { ... } needs no field of function type - it reads the field and configures the value it already holds, in place.

    type ServerOptions {
      var host: String = "localhost"
      var database: DatabaseOptions = DatabaseOptions()
    }
  5. Add a method only for what a plain field cannot do. A method that appends to a private collection, validates an argument, or takes more than one value belongs on the receiver type, not in the builder function.

    var fn route(path: String, to: String) {
      routes.append Route(path, to)
    }

Pitfalls#

  • Only the innermost receiver is implicit. Inside a nested block, a bare name resolves against the nested type, not the outer one - reaching the outer receiver needs its parameter named, which is a design CONCEPT.md describes and today's checker does not yet accept for a closure that reads it (see Receiver closures).
  • A field is written only with =, even a function-typed one; calling it always needs parentheses. onStart = { ... } assigns the closure, onStart() calls what is in it - the same line never means both.
  • A name the receiver does not have is a compile error at that line, with the receiver's own members offered. There is no silent fallback the way an unknown key in a map or a string-keyed configuration format would have.
  • A field the block sets has to be var. A const field is fixed after construction, so the builder's caller has no way to reach it from inside the block.

Full example#

type DatabaseOptions {
  var url: String = ""
  var poolSize: Int = 10
}

type Route {
  path: String
  handler: String
}

type ServerOptions {
  var host: String = "localhost"
  var port: Int = 8080
  var database: DatabaseOptions = DatabaseOptions()
  protected var routes: List<Route> = []

  var fn route(path: String, to: String) {
    routes.append Route(path, to)
  }
}

fn serve(configure: (var self: ServerOptions) => Void): ServerOptions {
  var options = ServerOptions()
  configure options
  options
}

const options = serve {
  host = "0.0.0.0"
  port = 8443
  database {
    url = "postgres://localhost:5432/app"
    poolSize = 20
  }
  route "/health", to: "health"
}

print "{options.host}:{options.port} {options.database.url} {options.routes.length()}"
Esc