Control flow#

Meta: keep if, case, and loop close together — they're all expression-form. The "no statements vs. expressions" point is worth stating once at the top.

Dang has no control-flow statements. if, case, loop, and try are expressions: each yields the last expression of whichever branch ran, so anything you can do with a value — bind it, return it, pass it as an argument — you can do with a conditional or a loop.

The examples on this page are live: they share one Dang environment, so later snippets use earlier definitions. Each result is computed and baked in by the docs build — edit a snippet and hit Run ▶ to replay the page in your browser. Blocks that show an error are supposed to fail: the build verifies the failure the same way it verifies the results.

Everything is an expression#

All four forms, sitting in expression position as list elements:

[
  if (true) 1 else 2,
  case { else => 3 },
  loop { break 4 },
  try { raise "!" } catch { err => 5 },
]
=> [1, 3, 4, 5]

The rest of this page covers if, case, and loop, plus the jumps — break, continue, return — that cut across them. try/catch/raise have their own page: Errors: try, catch, raise.

if / else#

Each branch of an if is a plain expression:

let ready = true

if (ready) "on" else "off"
=> on

Braces aren't part of the if syntax — a braced branch is just a block grouping several expressions into one (see Blocks):

if (ready) { let msg = "on", msg.toUpper } else { "off" }
=> ON

The condition must be a Boolean! — there is no truthiness, and a non-Boolean condition is a compile error:

if (1) "yes" else "no"
Type error: condition must be Boolean, got Int!

else if chains aren't special syntax either: the else branch is simply another if expression:

grade(score: Int!): String! {
  if (score >= 90) "A" else if (score >= 80) "B" else "C"
}

grade(85)
=> B

With no else, the expression is null whenever the condition is false, so its type is nullable — and an else if chain with no final else is likewise nullable. (Not even a return from the then-branch can undo that; see return below.)

if (false) "value"
=> null

Narrowings from the condition apply per branch (then = truthy, else = falsy; see Flow-sensitive narrowing). Testing nickname against null is what makes .toUpper — a String! method — safe in the else branch:

let nickname = "zoe" :: String

if (nickname == null) "anonymous" else nickname.toUpper
=> ZOE

Branches must merge to a common type; branches that diverge widen to a union instead (see Flow-sensitive narrowing). pet here is a Cat! | Dog!, which case type patterns will take back apart shortly:

type Cat { name: String!, lives: Int! = 9 }
type Dog { name: String! }

let pet = if (grade(95) == "A") Cat(name: "Whiskers") else Dog(name: "Rex")

case#

case compares an operand against clauses, top to bottom — the first match wins, so a duplicate clause (or anything after an early else) is simply never reached:

case (1 + 1) {
  2 => "first match wins"
  2 => "a duplicate is never reached"
  else => "no match above"
}
=> first match wins

Clause bodies merge to one common type when they can; bodies that diverge widen to a union instead, exactly like if branches (see Flow-sensitive narrowing) — this one is a String! | Int!:

case (1) {
  1 => "one"
  2 => 2
}
=> one

And when nothing matches and there's no else, the case is null — so, like a no-else if, its type is nullable. Adding an else keeps the result non-null. (So is covering every possibility; see Type patterns below.)

case (7) { 1 => "one" }
=> null

Value patterns#

A value pattern is a literal scalar — an int, float, string, boolean, enum value, or null:

let digit = 2

case (digit) {
  1 => "one"
  2 => "two"
  else => "?"
}
=> two

null is just another pattern:

case (null :: Int) {
  1 => "one"
  null => "nothing"
}
=> nothing

Literal patterns coerce to the operand's scalar or enum type — an operand of a custom scalar like URL! matches a "https://…" clause, an enum operand matches "ACTIVE" — the same coercion as at argument and return boundaries (see Enums and scalars). Only syntactic literals coerce; a non-literal value of a different type is a compile error (clause N value type mismatch).

Type patterns#

A type pattern has the form binding: Type => expr; the binding is the operand narrowed to that type inside the clause. Here it takes apart the Cat! | Dog! union pet picked up under if/else above:

case (pet) {
  c: Cat => `a cat with ${c.lives} lives`
  d: Dog => `a dog named ${d.name}`
}
=> a cat with 9 lives

Type patterns covering every member of a non-null union are exhaustive: nothing can fall through, so no else is needed and the result stays non-null — the case above is a String!. Leave a member out and the usual nullable fallthrough applies — pet is a Cat, so this is null:

case (pet) { d: Dog => d.name }
=> null

The operand must be a union or an interface (see Interfaces and unions) — a plain object type is already fully known, so there is nothing to narrow:

case (Cat(name: "Solo")) { c: Cat => c.name }
Type error: type pattern requires a union or interface operand, got object type Cat

And the named type must be one of the operand's actual possibilities:

case (pet) { s: String => s }
Type error: type String is not a member of union Cat! | Dog!

An interface-typed operand works the same way, with patterns checked against its implementers — and an interface is itself a valid pattern, a typed catch-all matching any implementer, so specific types go first. An interface's implementer set is open, so matching specific implementers is never exhaustive: it takes that catch-all (or an else) to keep the result non-null, as in play returning String! here:

interface Sound { noise: String! }
type Bell implements Sound { noise: String! = "ding" }
type Horn implements Sound { noise: String! = "honk" }

play(s: Sound!): String! {
  case (s) {
    b: Bell => `a bell goes ${b.noise}`
    other: Sound => `something goes ${other.noise}`
  }
}

[play(Bell), play(Horn)]
=> [a bell goes ding, something goes honk]

catch clauses use these same type patterns, over Error implementers — see Errors: try, catch, raise.

Optional operand#

Omitting the operand desugars to case (true): each clause is then a Boolean! condition, making case Dang's cond-style conditional chain:

let temp = 35

case {
  temp < 0 => "freezing"
  temp > 30 => "hot"
  else => "mild"
}
=> hot

loop#

loop { ... } is Dang's only loop — and it's a stdlib builtin (see Standard library reference), not a keyword: it calls its block over and over, forever, until a break, return, or raise exits it.

loop { break 42 }
=> 42

There is no for or while. Collections are iterated with .each and friends (see Collections and Blocks), and a conditional loop is a loop with a guard at the top:

let n = 1
loop {
  if (n >= 100) { break }
  n = n * 2
}

n
=> 128

A loop is an expression: it yields whatever value its break carries. When every break carries a non-null value the loop's type is non-null too — this one is an Int!, usable directly in arithmetic — while a bare break yields null and makes it nullable:

loop { break 42 } + 1
=> 43

break and continue#

break and continue are valid only inside a block being passed to a call — .each, loop, a user-defined block-taking function; there is no other category, since a loop body is just an ordinary block argument. Anywhere else they're compile errors (continue reports the same way):

break
Type error: break outside of block-taking call

break exits the nearest enclosing block-taking call, and break value makes it yield value (a bare break yields null). .each normally returns its receiver, but a break overrides that:

[5, 10, 15, 20].each { x => if (x > 12) { break x } }
=> 15

continue ends the current iteration. In .map, continue value supplies that element's result — so this bare continue inserts null:

[1, 2, 3].map { x => if (x == 2) { continue }, x }
=> [1, null, 3]

In .each and loop there is no per-element result, so continue just advances:

let sum = 0
[1, 2, 3, 4].each { x => if (x % 2 == 1) { continue }, sum += x }

sum
=> 6

Both target the nearest enclosing block-taking call only, and an ordinary function declared inside a block does not inherit that target — a break there is the same compile error as outside. (More block-specific wrinkles in Blocks.)

return#

return exits the enclosing function, method, or constructor early — there is no return for the normal result, since the last expression already is the result (see Functions). Its value must satisfy the declared return type, and there must be a function to exit:

return "early"
Type error: return outside of function

It unwinds through any blocks and loops in between, so returning from inside .each exits the whole function:

firstEven(nums: [Int!]!): Int {
  nums.each { n => if (n % 2 == 0) { return n } }
  null
}

firstEven([3, 7, 8, 9])
=> 8

A return in a skippable branch — a no-else if, say — does not make the function non-null. shout's only exit with a value is a String!, but the branch may be skipped, so its return type stays nullable:

shout(word: String): String {
  if (word != null) { return word.toUpper }
}

[shout("hey"), shout(null)]
=> [HEY, null]

And return is not an error: try/catch cannot intercept it. (sneaky is a zero-arg function, so referencing it calls it — see Functions.)

sneaky: String! {
  try { return "returned" } catch { err => `caught: ${err.message}` }
}

sneaky
=> returned

try / catch / raise#

Errors are expressions with the same shape — a try/catch yields a value, and raise fits in any branch — but they earn their own page: Errors: try, catch, raise.