Sscalascript.dev

Direct-syntax do-notation

Status: ✅ Landed — v1.8 (core) + v1.8.1 (extensions). All three backends (interpreter, JVM, JS) ship the feature.

ScalaScript 2.1 standard path. The compiler-free self-hosted frontend owns the bounded explicit forms direct[Option] and direct[List]: fresh assignments bind through portable flatMap, Option short-circuits, List keeps source-order Cartesian expansion, and pure val, mutable var, and nested direct blocks retain their lexical behavior. Unsupported monads fail explicitly. Type-directed blocks, postfix .!, pure auto-lift, Async/custom monads, and the wider v1 contract still require the explicit --compat-frontend; the standard launcher never falls back transparently.

Source of truth for the user-facing syntax, its lowering, and the seven locked design decisions. See also specs/backend-spi.md §6 (effect lowering) and the worked example in docs/tutorial.md §3.

1. Motivation

Today, sequencing two Async operations in a handler body looks like one of these three forms. None are ergonomic:

// (a) Callback nesting — works, but inverts control flow
route("GET", "/user") { req =>
  Async.flatMap(Async.delay(loadUser(req))) { user =>
    Async.map(Async.delay(loadOrders(user.id))) { orders =>
      Response.json(user, orders)
    }
  }
}

// (b) for-comprehension — works, but visually heavy and requires
//     the `<-` arrow to be remembered
route("GET", "/user") { req =>
  for
    user   <- Async.delay(loadUser(req))
    orders <- Async.delay(loadOrders(user.id))
  yield Response.json(user, orders)
}

// (c) Direct-syntax (this design) — reads like sync, types are honest
route("GET", "/user") { req =>
  user   = Async.delay(loadUser(req))
  orders = Async.delay(loadOrders(user.id))
  Response.json(user, orders)
}

Form (c) is what every modern effect system has converged on — Scala 3.5+ capture checking via boundary/break, Kotlin suspend, Rust async/await, OCaml 5 algebraic effects, ZIO 2 direct-style, cats-effect 3 with IO.uncancelable's direct shape, Unison. ScalaScript ships it as pure sugar over the v1.1 std/monad machinery — zero new runtime, zero new type-system primitives.

The non-goal

This is not a new effect system. It's not algebraic effects, it's not capability tracking, it's not a substitute for for { … } yield …. for-comprehensions stay the canonical multi-monad form. Direct syntax is the single-monad sugar that wraps the 80% case: "this whole function lives in one effect, please stop making me write <- on every line."

2. Worked examples

2.1 Implicit (type-directed) direct block

The handler's expected return type is Request => Async[Response]. The block body is therefore in Async:

route("GET", "/api/user/:id") { req =>
  // Compiler sees the expected type Async[Response], lowers the
  // body to a for-comprehension over Async.
  id      = req.params("id").toInt    // pure binding — no bind
  user    = Async.delay(loadUser(id)) // monadic bind — Async[User]
  orders  = Async.delay(loadOrders(id))
  Response.json(user, orders)         // pure — last expression
}

2.2 Explicit direct[M] marker

When context is ambiguous (e.g. assigning a direct block to a val that doesn't constrain the monad), an explicit marker:

val computeStats = direct[Async] {
  raw    = fetchRaw()                 // Async[String]
  parsed = parse(raw)                 // pure (no lift)
  count  = lookupCounter(parsed)      // Async[Int]
  count * 2
}

2.3 Control flow

route("POST", "/orders") { req =>
  user = Async.delay(loadUser(req))
  if user.tier == "gold" then
    discount = Async.delay(lookupDiscount(user))
    Response.json(applyDiscount(req.body, discount))
  else
    Response.json(req.body)
}

Both branches must inhabit the same monad after desugaring; pure branches auto-lift.

2.4 Loops

route("GET", "/users") { req =>
  ids = Async.delay(loadUserIds())
  // for-loop over a collection becomes traverse:
  users = ids.traverse(id => Async.delay(loadUser(id)))
  Response.json(users)
}

3. Grammar

A direct block is either:

where M has a Monad[M] instance in scope, AND the block contains at least one bind-form (x = expr or bare M[*]-typed expression). Pure blocks remain pure.

the M[*] expected type for the body. M must resolve to a monad via summon[Monad[M]] at the call site.

Inside a direct block:

Syntactic formLowering
val x = exprPure local binding. Body lifted via Monad.pure(expr) only at the block's end.
x = expr (no val)Monadic bind. Becomes x <- expr in the lowered for-comprehension; if expr's type is not M[*], auto-lifts via Monad.pure.
Bare expr of type M[*]Becomes _ <- expr (bind-and-discard).
Bare expr of pure typeRegular Scala statement (no bind).
var v = expr / v = exprExisting Scala mutable-var semantics — never monadic. The var keyword disambiguates from monadic bind.
Last expressionThe yield clause. Pure values auto-lift via Monad.pure.

4. Design decisions (DS-1 … DS-7)

#QuestionResolution
DS-1How does the typer infer the monad for a direct block?Type-directed — inferred from the expected return type (e.g. handler typed Request => Async[Response] ⇒ block in Async). Explicit direct[M] { ... } marker as fallback when context is ambiguous.
DS-2When do pure values auto-lift?val x = expr is a pure local binding (no bind). x = expr is a monadic bind; pure expr auto-lifts via Monad.pure. var-rebind keeps Scala mutable-var semantics.
DS-3Bare statements — bind-and-discard or regular?Type-directed — bare expr of type M[*] becomes _ <- expr; pure bare expressions stay regular statements (e.g. assert(x > 0)).
DS-4Control flow inside direct blocks (if/match/while/for)?All branches must inhabit the same monad after lifting. Pure branches in an if/match auto-lift to M[*] if any sibling branch is M[*]. while/do-while of an M[Unit]-typed body desugars to Monad.whileM_(cond, body); backends ship whileM_ as a stdlib helper. for (x <- xs) body where body: M[Unit] desugars to xs.traverse_(x => body).
DS-5Lambdas inside collection ops (xs.map(x => doMonadic(x))) — do they "see" the outer direct block?No — lambda bodies are independent direct blocks. A lambda's expected return type drives its own monad inference: a lambda typed A => M[B] is its own direct block; a lambda typed A => B is pure. Cross-boundary effects require xs.traverse(f) (lambda returns M[B], traverse threads the monad) rather than xs.map(f) (lambda must return pure). .map(x => doMonadic(x)) raises a type error directing the user to .traverse.
DS-6Explicit bind-marker syntax (postfix .!, prefix ~, await)?No marker in v1. Inference is pure-type-directed. Locked deliberately to avoid a second syntactic dialect. A follow-up postfix .! operator for explicitly forcing bind in genuinely ambiguous spots is parked for v1.8.1 once real usage surfaces ambiguity; until then, users write _ = expr to force the bind.
DS-7Error handling — MonadError or thrown exceptions?Both, type-directed. M.fail(...) / M.recover(...) are the canonical monadic API. Additionally, throw e: E / try { … } catch case e: E => … inside a direct block lower to F.fail / F.handleError when MonadError[F, E] is in scope (the typer-directed bridge — see docs/error-handling.md §2.5.6); they keep their JVM-native semantics otherwise. This narrows the original DS-7 lock from "thrown exceptions NEVER auto-wrap" to "thrown exceptions auto-wrap only when the user explicitly typed them AND the F advertises a matching error channel" — the two-fault-model trap is still avoided because the lowering is driven by what the user typed, not by silent magic on every Throwable.

Locked 2026-05-17 (DS-1…DS-3, DS-7); DS-4…DS-6 locked 2026-05-18 (this document).

5. Desugaring

The transformer runs after typing and before backend-specific lowering. Pseudo-code:

def desugar(block: DirectBlock[M]): Term =
  val stmts = block.statements
  val tail  = block.tail            // the final expression
  // Walk stmts in reverse, building nested for-comprehension binds.
  val (binds, pureStmts) = stmts.foldRight((List.empty[Bind], List.empty[Stat])) {
    case (Stat.ValDef(x, rhs), (bs, ps)) =>
      // `val x = expr` — pure local, kept as-is in the yield body
      (bs, Stat.ValDef(x, rhs) :: ps)
    case (Stat.Assign(x, rhs), (bs, ps)) if isMonadic(rhs.tpe) =>
      // `x = expr` with M[*] rhs — true monadic bind
      (Bind(x, rhs) :: bs, ps)
    case (Stat.Assign(x, rhs), (bs, ps)) =>
      // `x = expr` with pure rhs — pure-lifted bind
      (Bind(x, q"Monad[M].pure($rhs)") :: bs, ps)
    case (Stat.ExprStat(e), (bs, ps)) if isMonadic(e.tpe) =>
      // bare monadic expr — bind-and-discard
      (Bind("_", e) :: bs, ps)
    case (stmt, (bs, ps)) =>
      // pure statement — keep in yield body
      (bs, stmt :: ps)
  }
  q"""for {
        ..${binds.map { case Bind(x, e) => q"$x <- $e" }}
      } yield {
        ..$pureStmts
        ${if isMonadic(tail.tpe) then tail
          else q"Monad[M].pure($tail)"}
      }"""

The result is a vanilla for { x <- e1; y <- e2 } yield body — the existing v1.1 Monad instance machinery handles the rest. No new runtime, no new IR nodes.

Control flow

if cond then thenBranch else elseBranch where the block's expected type is M[A]:

while cond do body where body: M[Unit]:

Monad[M].whileM_(cond)(body)

whileM_ is a stdlib helper in std/monad-control.ssc (lands with this milestone). The v1.8.x follow-up adds two complementary combinators in the same module:

loops while !cond. Returns the last successful body result wrapped in F. Stdlib spellings: untilMResultOption, untilMResultEither.

init, while cond(current) is true threads current through the monadic step. Check-first: if cond(init) is false the initial value is returned via pure. Stdlib spellings: iterateWhileMOption, iterateWhileMEither.

Both short-circuit on monadic failure (None for Option, Left for Either), matching the contract of whileM_.

for x <- xs do body where body: M[Unit]:

xs.traverse_(x => body)

xs.traverse_ from std/foldable-traversable.ssc (already in v1.1).

6. Edge cases

return and non-local exits

Disallowed inside direct blocks. Use M.fail(...) for early failure. Reason: return from a desugared for-comprehension bypasses the monad's bind chain and breaks effect semantics — e.g. cancellation, retry, finalisers.

Mutable var interacting with bind

var counter = 0
direct[Async] {
  result = Async.delay(fetchSomething())
  counter += 1              // pure side-effect on the var — OK
  result.length
}

The var is lexical; the desugared for-comprehension closes over it normally. Disallowed: assigning a monadic result to a var:

var x = 0
direct[Async] {
  x = Async.delay(1)        // ERROR: monadic bind not allowed on var
}

Compiler emits "monadic bind requires val or fresh name; got mutable var".

Nested direct blocks

direct[Async] {
  outer = Async.delay(prepare())
  innerResult = direct[Option] {
    a = parseInt(outer)
    b = parseInt(outer.tail)
    a + b
  }
  Response.json(innerResult)
}

Inner block is in Option; outer in Async. Lifting between monads is not automatic — the inner block produces Option[Int], which the outer treats as a pure value. Transformer stacks (OptionT[Async, *]) are out of scope for v1.

Pure-only block

direct[Async] {
  a = pureCompute()         // auto-lift via Monad.pure
  b = pureCompute2()
  a + b                     // pure tail — auto-lift
}

Compiles to Monad[Async].pure(pureCompute() + pureCompute2()) — no real binding happens, but the user can write the code uniformly.

7. Comparison

vs. for { … } yield …

For-comprehension is the canonical form and continues to work unchanged. Direct syntax is sugar that compiles to a for-comprehension — both forms can be mixed freely in the same codebase, and a direct block can contain a nested for-comprehension or vice-versa.

When to prefer which:

Either + Async via EitherT), pedagogical clarity, or when explicit <- is a feature.

monad, especially when it has 4+ binds.

vs. Scala 3.5 capture checking / boundary/break

Different problem. Capture checking tracks which captures cross which boundary at the type level (where can s escape?). Direct syntax tracks sequencing of monadic operations. The two are orthogonal — direct syntax could later use capture checking to verify that no var reaches across an Async.parallel(...).

vs. cats-effect 3 / ZIO 2 direct-style

Same idea, different ecosystem. Cats-effect uses IO.flatMap and the user explicitly threads via >>/for. ZIO's direct-style preview uses for-yield with hints. Our shape is closer to Unison's unison-do blocks or Kotlin's suspend — pure-type- driven, no marker, single monad. Trade-off: less explicit, more ergonomic; works when the team already accepts monadic effect abstractions (v1.1 stdlib already does).

8. Implementation phases

Each phase is a separate PR, mergeable in sequence.

Phase 1 — Typer foundation (~3 days)

block's inferred result type.

Phase 2 — Desugaring transformer (~4 days)

takes over from there.

Phase 3 — Type-directed mode (no marker required, ~3 days)

with a Monad[M] in scope AND contains a bind-form (x = expr or bare M[*]-typed expr).

Phase 4 — Control flow + traverse helpers (~2 days)

Phase 5 — Diagnostics (~2 days)

Phase 6 — Conformance + std rewrites (~2 days)

(direct/inference.ssc, direct/pure-lift.ssc, direct/control-flow.ssc, direct/traverse.ssc, direct/error.ssc, direct/nested.ssc).

examples/async-parallel-demo.ssc) to direct syntax — they're the touch points users land on.

Total: ~16 days (~3 weeks).

9. Hard-no list (closed by design)

FeatureReason
Full effect-row composition (`Async \Random` sharing a single monad)Out of scope for v1; `direct[Async \Random]` is accepted syntactically (v1.8.1) and duck-typed but does not compose two monads.
Fully general monad transformers (StateT, WriterT, …)Out of scope; cross-monad lifting is limited to Option↔Either in v1.8.1.
Silent auto-wrap of any Throwable into M.failThe two-fault-model trap — DS-7 (refined 2026-05-18) only auto-wraps when the user explicitly typed the throw AND MonadError[F, E] is in scope; never on bare throw new RuntimeException(...)
Capability-checked direct[Pure] { ... }No Monad[Pure] in std today; would need its own foundation.
await-style keyword (val x = await(expr))Locked under DS-6 — pure type-directed, no marker.
Non-local return from inside a direct blockBypasses bind chain; use M.fail.

10. v1.8.1 extensions — landed

Three follow-ups from v1.8 shipped in v1.8.1:

10.1 Postfix .! explicit-bind operator (DS-6 follow-up)

Inside any direct[M] block, appending .! to an expression forces a monadic bind at that point and returns the unwrapped value in-place:

direct[Option] {
  println(Some(42).!)          // prints 42; result discarded
  Some(Some(10).! + Some(32).!)  // => Some(42)
}

fa.! desugars via A-normalization in DirectAnorm.expand: each .! occurrence is lifted into a fresh _bN = fa bind statement prepended before the enclosing statement, and replaced by _bN at the original position. The A-normalization pre-pass runs on all three backends (interpreter, JVM codegen, JS codegen) via core/transform/DirectAnorm.scala.

Boundaries where .! is not lifted: nested direct[M] blocks, lambda bodies, and Term.Block sub-expressions — each of these forms its own scope.

10.2 Effect-row union types

direct[Async | Random] is now accepted without a parse or validation error. DirectTypeUtils.validateDirectTypeArg permits |-connected union types and rejects any other infix type operator. At runtime the leftmost type name (Async in the example) is used as the primary monad for duck-typed flatMap dispatch — full multi-monad composition is still out of scope for v1 (see §9 hard-no).

10.3 Transformer-aware lift (interpreter)

When a direct[M] block binds a value of a different compatible monad, the interpreter auto-lifts it instead of dispatching flatMap on the wrong type:

Outer MBound valueLift
OptionRight(v)extract v, continue
OptionLeft(_)short-circuit to None
EitherSome(v)extract v, continue
EitherNoneshort-circuit to Left(())
Async / otherOption/Either valuessame rules as above
case class Right[A](value: A)
case class Left[A](value: A)

val r = direct[Option] {
  x = Right(42)         // auto-lifted: x = 42
  Some(x * 2)
}
// => Some(84)

Implementation: DirectMonadTag enum (OptionM / EitherM / AsyncM / ListM / OtherM) is extracted from the direct[M] type argument; liftBindValue in the interpreter selects the lift rule before falling back to duck-typed flatMap.

Earlier follow-ups

iterateWhileM, loop combinators beyond whileM_.~~ Landed v1.8.x as untilMResult{Option,Either} and iterateWhileM{Option,Either} — see §5 control flow above.

Still open

leak var-captures across Async.parallel, once Scala 3.x capture checking matures.

11. Conformance plan

Six tests, each a single ScalaScript file under conformance/:

TestExercises
direct-inference.sscDS-1: type-directed inference vs explicit direct[M]
direct-pure-lift.sscDS-2, DS-3: pure auto-lift, bare M[*] discard
direct-control-flow.sscDS-4: if/match/while/for desugaring
direct-lambdas.sscDS-5: lambda boundaries, .traverse vs .map
direct-error.sscDS-7: M.fail / M.recover; thrown exceptions don't auto-wrap
direct-nested.sscNested direct blocks across Async / Option (no transformers)

Each test runs on all three backends (INT, JS, JVM) under the existing conformance/run.sc harness. Behaviour is identical across backends — direct syntax is pure source-to-source rewriting, no runtime divergence.