Nanyx

Workflows

Builder-based workflows and custom keywords

Workflows are one of Nanyx’s most distinctive features. They let libraries provide language-like constructs by rewriting a block into method calls on a builder value. This makes patterns like async/await, error chaining, and DSLs feel native.

The builder shape

A workflow builder is a value with an apply function and a set of keyword methods such as yield, await, try, or return. Writing a block after the builder’s name:

someBuilder {
  -- block content
}

desugars to a call to the builder’s apply function, with the block body running under use someBuilder so that its keyword methods are in scope:

someBuilder.apply({
  use someBuilder in
    -- block content
})

Every keyword method takes two arguments: the value the keyword was applied to, and a continuation containing the rest of the block. This is the one desugaring rule for all workflows; everything else on this page is a consequence of it.

The simplest possible workflow can be illustrated with a builder that just provides a yield method that dumps the given value out to debug:

def debug = (
  apply = { body -> body() }

  @customKeywordType(#bind)
  yield = { value, continuation ->
    hostDebug(value)
    continuation()
  }
)

debug {
  yield "Debug message 1"
  yield "Debug message 2"
}

The block content is rewritten to nested calls to the yield method, each receiving the rest of the block as its continuation:

debug.apply({
  yield("Debug message 1", {
    yield("Debug message 2", {
      -- end of block
    })
  })
})

Because yield here calls its continuation immediately, the block runs top to bottom like ordinary sequential code. A keyword that chooses not to call its continuation stops the rest of the block from running, which is how short-circuiting keywords such as try work (see below).

Applying a keyword to a value without parentheses, as in yield 1 or try f(), is sugar reserved for workflow keywords; ordinary functions are always called with parentheses.

Yield and collection builders

The standard seq builder provides yield, letting you build sequences with a clean syntax:

def numbers = seq {
  yield 1
  yield 2
  yield 3
}

Under the single desugaring rule this becomes:

seq.apply({
  yield(1, {
    yield(2, {
      yield(3, { })
    })
  })
})

Note that yield is special in that it can be used implicitly when the expression returns a value:

seq {
  1
  2
  3
}

Async and custom keywords

Workflows can introduce custom keywords. For example, an async workflow can expose await by implementing a method tagged as a keyword.

def greetingAsync = async {
  def name = await getName()
  return "Hello, {name}"
}

That expands into calls on the async builder, with the binding def name = await ... becoming the parameter of the continuation:

async.apply({
  await(getName(), { name ->
    return("Hello, {name}", { })
  })
})

A keyword is any builder member that carries the @customKeywordType attribute. The attribute’s argument says how the keyword’s arguments are passed (see Keyword kinds). There is no naming convention for keywords; await, try and yield are ordinary member names that happen to be marked as keywords by the builders that define them.

def retry = (
  apply = { body -> body() }

  @customKeywordType(#bind)
  retry = { m, continuation ->
    ...
  }
)

Common keywords

KeywordDescription
yieldProduce a value in a collection builder
awaitAwait an asynchronous operation in an async workflow
tryShort-circuit on errors in a workflow that handles results
returnWrap the block’s final value and finish the block
zeroThe value of a block that produces no value (an if without else, or falling off the end without return)
deferRun a thunk after the rest of the block has finished

Keyword kinds

KindDescription
#bindThe keyword takes a value and a continuation function. The workflow will call the continuation with the result of the keyword operation.
#bindFLike bind, but a function returning the target value is passed instead of the value itself. This allows for workflows such as lazy evaluation and querying
More coming soon…

Workflow keywords

Builders expose keywords by marking members with @customKeywordType. Attributes may be placed before a field inside a record literal:

def async = (
  apply = { body -> ... }

  @customKeywordType(#bind)
  await = { m, continuation ->
    ...
  }

  @customKeywordType(#bindF)
  defer = { thunk, continuation ->
    continuation()
    thunk()
  }
)
def main = {
  async {
    defer println("This runs at the end of the block")
    println("This runs first")
  }
}

The block above never calls return, so its value is the builder’s zero.

Error and option chaining

A workflow can provide a try keyword to short-circuit on errors. This is useful for Result or option-like chaining.

def getCustomer: int -> Result(Customer, #customerNotFound) = ...
def getLastOrder: Customer -> Result(Order, #orderNotFound) = ...

def lastOrderTotal: () -> Result(Money, #customerNotFound | #orderNotFound) = proc {
  def customer = try getCustomer(5)
  def order = try getLastOrder(customer)
  return order.total
}

proc is the standard library’s result workflow. Its try calls the continuation with the payload of #some, and returns any #error value directly without calling the continuation, which is what makes the rest of the block skip. See Option chaining for a worked example of writing such a builder.

Early return and defer

A workflow can also provide a return keyword that exits the block early, and a defer keyword that runs a thunk at the end of the block.

def main = {
  async {
    defer println("This runs at the end of the block")

    if b then return "Early exit"

    println("This runs first")
    return "Done"
  }
}

Query and DSL workflows

Workflows are a natural fit for DSLs. A query builder can turn a block into a pipeline of filtering, joining, and projection steps.

def result = query {
  def customer = from customers
  def order = from orders
  join order.customerId == customer.customerId
  where order.total > 100
  select customer.name, order.total
}

Workflows are also how HTML-style builders or configuration DSLs are implemented.

When to use workflows

  • You want syntax that reads like a language feature, but is implemented in libraries.
  • You need structured control flow around effects (async, retries, error handling).
  • You want to provide a DSL for a narrow domain.