Skip to content

Expressions and control flow

Expression-valued branches, block tails, guarded bindings, loops, and value-position limits.

Updated View as Markdown

Hem’s if, match, try, and postfix catch are expression-valued forms. In the supported positions listed below, the compiler preserves evaluation order and emits standard TypeScript control flow. For unsupported positions and alternatives, see language boundaries. Arrow bodies can be expression-bodied or block-bodied, with the final block expression serving as the return value.

Value-producing forms

if

Parentheses around the condition are not used, and braces are required. Both branches contribute to the result type. A value-position if without else produces ?T because its false path has no value.

let size = (n: number): string =>
  if n > 1000 { "large" } else { "small" }

let label = (enabled: boolean): ?string =>
  if enabled { "on" }

In statement position, if emits a standard TypeScript if. A guard branch that diverges with return, raise, throw, continue, or break needs no value, and an absent else does not introduce a temporary variable.

match

match selects one arm without fallthrough and returns that arm’s value. Exhaustiveness and redundancy are checked for closed types. Patterns, guards, as-bindings, structural records, and tuple scrutinees are documented in pattern matching.

type State = | Ready | Busy

let describe = (state: State): string => match state {
| Ready => "ready"
| Busy => "busy"
}

An arm containing a diverging expression, such as raise, does not need to match the surrounding type. Matching open structural objects requires a final _ arm; see the pattern reference for coverage rules.

try and postfix catch

try { ... } catch pattern { ... } is an expression-valued protected region. Its final expression, or the final expression of its handler, provides the value. Postfix catch works the same way and attaches to any expression, including await. Failure semantics and arm forms are in errors and failures.

Arrow and block expressions

Arrows are the only function form. An expression body returns its expression. A block body runs its statements in order and returns its final expression; a block with no final expression returns void according to its contextual TypeScript type.

let normalize = (text: string): string => {
  let trimmed = text.trim()
  trimmed
}

The same block-tail rule applies inside a value-producing if, match arm, try, or catch handler. Each branch is checked against the contextual type of the enclosing expression.

Statement-shaped control flow

The compiler emits standard statements whenever the source is in statement position. A guard is written cleanly in Hem and remains clear in emitted TypeScript:

error InvalidCount({ value: number })

let requirePositive = (value: number): number => {
  if value <= 0 { raise InvalidCount({ value }) }
  value
}

The branch raises and the following expression supplies the successful value. The same rule applies to return, throw, continue, and break: a diverging branch does not require a dummy value or an extra else block.

let-else

let pattern = scrutinee else diverging-expression binds a refutable pattern or takes the diverging else branch. The else branch must diverge with return, throw, raise, continue, or break; a branch that produces an ordinary value causes HEM2014. The scrutinee is evaluated once, and successful bindings are available after the statement.

let firstOrZero = (values: number[]): number => {
  let Some(first) = values[0] else return 0
  first
}

The refutable pattern set includes Some, constructors, literals, records, arrays, and as aliases. Omitting else on a root canonical Some, Ok, or native ?T binding propagates the unchanged residual to the nearest arrow, async arrow, or Effect block. An Effect block receives it on its success channel; postfix ! is the separate operation that converts a canonical carrier’s residual into an Effect failure. See absence (?T) and standard carriers.

for await

let printLines = async (lines: AsyncIterable<string>) => {
  for await line in lines { console.log(line) }
}

for await pattern in source lowers to native for await (const pattern of source). It is legal inside async arrows and at module top level, wherever core await is legal. Binding patterns, including destructuring and as aliases, and break/continue work as in for; the loop is a statement and yields no value. A non-async arrow receives TypeScript’s TS1103 diagnostic at the loop’s await keyword.

Ranges count synchronously: use for i in a..b (HEM3007). Inside effect {} or resource {} blocks, await lifts a single Promise, so for await is refused (HEM4119). Use Stream.fromAsyncIterable with Stream.runForEach, Effect.forEach over an array, or an async helper arrow defined outside the block and lift its Promise with await.

while let

while let pattern = scrutinee { ... } is the loop form of a refutable binding. Hem re-evaluates the scrutinee at the start of every iteration, stops when the pattern fails, and scopes the successful bindings to that iteration. It has no else and produces no value.

let sum = (values: number[]): number => {
  let remaining: mut number[] = [...values]
  let mut total = 0
  while let Some(value) = remaining.pop() {
    total += value
  }
  total
}

The loop pops from an explicitly mutable copy, leaving the caller’s values unchanged.

The pattern in the loop header must be refutable. A bare binding, _, or a record/array containing only bindings always matches and triggers HEM2022; use a plain while condition instead. for loops and ranges are also statements. Ranges such as 0..limit and 0..=limit are valid only in a for loop header, not as standalone expressions.

Value-position limits

Hem lowers value-producing control flow into declared-then-assigned TypeScript while preserving evaluation order. When a match, an if, or a statement block is the value a function returns, each branch or arm returns directly instead, so no temporary appears. Nested try, match, and statement-bearing if expressions work in ordinary method arguments and on the right of &&, ||, and ??. A skipped right operand stays skipped. For a method call, the receiver and method are captured before the arguments, and this still refers to the original receiver. Arguments evaluate from left to right.

Existing optional-call forms also accept these arguments. A short-circuited receiver or callable skips all argument work, including recovery. Parentheses retain their usual optional-chain boundary.

let risky = (): number => { throw new Error("unavailable") }
let recover = (): number => 3
let invoke = (callback: ?((value: number) => number)): ?number =>
  callback?.(risky() catch { | _ => recover() })

The catch protects only risky(). It does not catch failures from recover() or callback.

let hasText = (text: ?string): boolean =>
  text != None && try { text.length > 0 } catch { false }

let fallbackCount = (count: ?number): number =>
  count ?? try { 42 } catch { 0 }

These limits remain:

  • These hoists work at value boundaries such as initializers, return tails, and arm bodies. Statement if/while conditions and match guards still reject nested control flow; compute a condition before if, or inside each loop iteration, rather than moving repeated work outside a loop.
  • Nested control flow in super calls and method-tagged templates is refused with HEM3003. Keep the conditional call explicit and bind its argument inside that branch.
  • Synchronous postfix catch supports the existing value-position paths, including optional-call arguments. Arbitrary standalone statement blocks do not support every value-position form.
  • Call-parameter contextual types are not passed into hoisted temporaries. If inference needs one, use an annotated let before the call.
  • Loops are statements and cannot be used as expressions.
  • while let has no else and no value.
  • A range is valid only in a for header, not as a standalone expression.

The compiler does not wrap standard code in an IIFE to work around these cases. The only exception is a control-flow expression inside JSX braces, where the emitted code must remain an expression; see the JSX reference.

Normative source

Specification 0.2, §4 (bindings), §5 (arrows), §6 (expressions), and §7 (control flow). The value-position lowering engine follows the return and hoisting rules in specification 0.2.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close