Skip to main content

Type switches and checked exceptions

An interpreter for a tiny expression tree. It uses the two features that most change how Reed code is structured: a type switch to dispatch on which node a reference actually is, and a throws clause so the one operation that can fail is visible in every signature that carries it.

The program evaluates (6 / 2) + 4 to 7, then evaluates 1 / 0 and recovers.

// A node hierarchy. `Node` is the parent; each variant adds its own fields.
struct Node {}
struct Lit : Node { value: i32 }
struct Add : Node { left: &Node, right: &Node }
struct Div : Node { left: &Node, right: &Node }

tag DivideByZero(i32);

// The `throws (DivideByZero)` is part of the signature: every caller can see that
// evaluating an arbitrary node might fail, and the compiler makes them say what to do
// about it.
func eval(node: &Node) -> i32 throws (DivideByZero) {
  switch (node) {
    case &Lit: {
      // Inside the arm, `node` is narrowed to `&Lit`, so `value` is readable without
      // a cast. Narrowing works here because `node` is a parameter -- a bare immutable
      // binding.
      return node->value;
    }
    case &Add: {
      // Both recursive calls can throw, so both need `?`. Without it this is a compile
      // error, not a silent runtime trap.
      return eval(node->left)? + eval(node->right)?;
    }
    case &Div: {
      let divisor: i32 = eval(node->right)?;
      if (divisor == 0) {
        throw DivideByZero(0);
      }
      return eval(node->left)? / divisor;
    }
    default: {
      // Reached only if a new node type is added without a case for it.
      return 0;
    }
  }
}

// A caller that handles the failure instead of propagating it needs no `throws` clause of
// its own -- the `try` discharges the effect. Note there is no `?` on this call: the
// handler below already covers the only tag `eval` can throw, so nothing is left to
// propagate, and a `?` here would be an error.
func eval_or(node: &Node, fallback: i32) -> i32 {
  try {
    return eval(node);
  } catch DivideByZero(_) {
    return fallback;
  }
}

func check() -> i32 {
  // (6 / 2) + 4
  let ok: &Node = new Add {
    left: new Div { left: new Lit { value: 6 }, right: new Lit { value: 2 } },
    right: new Lit { value: 4 },
  };
  if (eval_or(ok, -1) != 7) { return 1; }

  // 1 / 0 -- throws, and the handler supplies the fallback.
  let bad: &Node = new Div { left: new Lit { value: 1 }, right: new Lit { value: 0 } };
  if (eval_or(bad, -1) != -1) { return 2; }

  return 0;
}

export func check as "check";

What the two features buy

The type switch replaces a chain of is tests plus casts. Each case lowers to a br_on_cast, and the arm body sees the scrutinee at the narrowed type. The equivalent written with if (node is &Lit) needs the same tests but re-states the type at each use. The restriction to bare immutable bindings is the same for both, and a scrutinee that cannot narrow (a var, a field read, a call result) is a warning at the switch, not a confusing error later — see type tests and narrowing.

throws plus ? makes the failure path a type-checked property rather than a convention. eval cannot silently start failing: adding a throw to it without updating the clause is an error, and every caller either handles the tag or marks the call with ? and declares it too. Removing the ? from any recursive call above is a compile error naming both options.

A ? that has nothing to propagate is also an error, which is what keeps the marks meaningful — they appear exactly where a call can fail, so the failure paths in a function can be read off its body.

See also