20 — Pitfalls (measured)

Previous: Targeting wasm

日本語版: 20_pitfalls.vibe.md

These are rules that cost people an afternoon. Each one was measured on the current compiler. The full list lives in docs/user/reference/cheatsheet.md. This chapter is the ones that bite first.

handle eligibility is not the type system

A program can type-check and still fail to compile if a handle cannot see every perform it covers. Two things make a call visible: the callee's type carries the effect row, or the callee is one the handle can already see through — a top-level fn, a binding that aliases one, or a closure declared inside the handled body.

So a call through a local binding or a parameter is fine when its type carries the row; being a local binding is not itself the problem. A rowless closure declared outside is also fine when its body is only pure computation — the handle still sees the arguments. What fails is a call with no visible body: an immediately-applied closure, or a lambda whose body performs or calls something this pass cannot name. The diagnostic names the edit and does not cite an ADR. The full measured table is in docs/user/reference/cheatsheet.md.

// skip: eligibility rejection — the callee is an expression, not a name
effect Ask {
  Get() -> Int
}

fn ask() -> Int with Ask {
  perform Ask::Get()
}

fn main() -> Int {
  handle { ((n: Int) -> Int { n })(ask()) } with {
    Ask::Get() => resume(0)
  }
}
handle of effect 'Ask' cannot be compiled here: the handled body calls through an expression rather than a name, and this handle cannot see what that call performs. Give the callee a name it can see -- a top-level `fn`, or a `let` bound to the closure literal inside the handled body -- or move the `handle` into the function that performs.

Int width follows the tag bit

Int is 63-bit (one tag bit, ADR-0105). Literal max is 4611686018427387903. max + 1 wraps to -4611686018427387904 on every backend. Text that still says 2^61-1 / 62-bit is stale.

Interpolation needs a renderer

A user struct interpolated with \{x} needs derive(Show) or fn T::to_string(v) -> String. Scalars, Option, tuples, and arrays already render. Missing Show used to print a pointer; it is now an error (#1445).

s[i] is a byte, not a String

String is a byte string. s[i] is the byte at that offset, an Int. 'A' == 65. A one-byte String is String::from_char_code(s[i]) — a byte write, alias String::from_byte (#2203) — or a slice. That byte is a whole character only in ASCII.

Top-level is declarations only

A bare expression at the top level is rejected (ADR-0069). Put it in fn main or a test block.

// skip: ADR-0069 — a file is a list of declarations
1 + 2
top-level expressions are not allowed; move it into fn main

test / bench take a string or nothing

test { } and test "name" { } work. test foo { } (a bare identifier) does not.

Error vs Exception

Exception is the effect. Error as an effect spelling was retired (#1461): with Error is refused, and the message names Exception. You may still see Error::Throw as an operation qualifier in older code; it is accepted, but do not add new ones.

== on arrays

== compares by value, including arrays with non-scalar elements, arrays returned from a function, and empty literals — annotated or not. An unannotated let xs = [] takes its element type from the Array::push calls that fill it (#2157), as long as the pushed value says what it is: a literal, an array / tuple / struct of literals, or an if whose branches agree.

Push a name or a call result and the binding's syntax does not say the element type. vibe run and vibe test still answer by content when that element is one == can compare, including Double, Bytes and arrays. let xs: Array[Int] = [] is the form that does not depend on that. A generic struct is compared at each concrete instantiation, so Box[Double] and Box[Array[Int]] compare by content too. What fails is an element with no structural comparator, and it fails as a build error, not at run time. vibe check does not report it.

A generic T with no Eq witness is the boundary worth knowing, and it is a compile error rather than a surprise — no impl Eq for Array[Int]. See Equality.

A line-leading - is a prefix only when it touches its operand

A newline does not end an expression when the next line begins with an operator, with one exception: -, ! and ~ at the start of a line are prefix operators when nothing separates them from their operand (#3041). So -1 on its own line starts a new expression, while - 1 (a space after the -) continues the line above as a subtraction:

fn main allows Console {
  let a = 10
  let fresh = {
    println("computing")
    -1
  }
  let continued = a
    - 1
  let trailing = a -
    1
  println("\{fresh} \{continued} \{trailing}")
}
computing
-1 9 9

The space after the - is what decides, so write a subtraction that wraps as a - 1 on one line, as - 1 at the start of the next, or with the - ending the previous line. Every other line-leading operator (+, *, |>, &&, ...) always continues the previous line. A spaced - 1 after a statement that returns Unit is a type error whose message names both edits: drop the space, or write (-1).

fn is a keyword

let fn = 1 is a parse error. r#fn is not an escape hatch. Rename the binding.

for does not always collect

let xs = for x in arr { x * 2 } works for Array. A pull iterator in the same position is a located error. Accumulate with ArrayBuilder.

Next: Appendix.