Errors Are Not Exceptions
Part 03 of From Lists to Languages · Act I, Foundations ·
A language runtime should know how it can fail.
Part 2 made parsing honest. A program that reads but means nothing fails with a diagnostic that points at the source. Then it handed the parsed tree to an evaluator with this type:
evaluate: (e: Expr) => ValueThat type is a promise the function can't keep. Evaluating (+ 1 (quote x)) throws a TypeError, and evaluating y throws a ReferenceError. Neither error knows where in the source it happened. A caller who wants to handle one has to wrap everything in try/catch. That also catches every bug in the interpreter itself, because a thrown exception doesn't say whose fault it is.
This post rewrites the evaluator so that its type tells the truth:
evaluate: (e: Expr) => Effect<Value, EvalError, Environment>Read it as a sentence: evaluating an expression produces a Value, or fails with an EvalError, and it needs an Environment to run.
Name every failure
Start with the failures, because they shape everything else. Our little language can fail at runtime in exactly four ways:
// Every way evaluation can fail, as data: what failed, and where.
export class UnboundName extends Schema.TaggedError<UnboundName>()(
"UnboundName",
{ symbol: Schema.String, span: Span },
) {
override get message() {
return `${this.symbol} is not defined`;
}
}
export class NotCallable extends Schema.TaggedError<NotCallable>()(
"NotCallable",
{ symbol: Schema.String, span: Span },
) {
override get message() {
return `${this.symbol} is not a function`;
}
}
export class NotANumber extends Schema.TaggedError<NotANumber>()(
"NotANumber",
{ op: Schema.String, got: Schema.String, span: Span },
) {
override get message() {
return `${this.op} expects numbers, got ${this.got}`;
}
}
export class DivByZero extends Schema.TaggedError<DivByZero>()(
"DivByZero",
{ span: Span },
) {
override get message() {
return "division by zero";
}
}
export type EvalError =
| UnboundName
| NotCallable
| NotANumber
| DivByZero;Each error is a class built with Schema.TaggedError. That gives it three things:
- A tag, such as
"NotANumber", so code can tell failures apart without comparing message strings. - Fields with a schema. The failure is structured data.
NotANumberrecords the operator, what it got, and the span to blame. - Yieldability. Inside
Effect.gen,yield* new UnboundName(...)fails the computation with that error, and the error's type joins the computation's error type.
The message is derived from the fields, so the data is the source of truth. Serialized, a failure is plain JSON:
{ "_tag": "NotANumber", "op": "+", "got": "x",
"span": { "start": 5, "end": 14 } }EvalError is the union of the four. When a fifth way to fail appears, it gets added here, and the types follow it everywhere.
What evaluation needs
Part 2's evaluator had its primitives built in. Every name lookup either hit that hard-coded table or threw. Now the names a program can use live in an environment, and the evaluator asks for it instead of owning it.
Values come first. A value is a number, a piece of quoted data, or a primitive operation:
// Values: numbers, quoted data, and primitive operations. A
// primitive gets each argument with its span, so it can blame one.
export interface Arg {
readonly value: Value;
readonly span: Span;
}
export interface Primitive {
readonly _tag: "Primitive";
readonly name: string;
readonly apply: (
args: ReadonlyArray<Arg>,
) => Effect.Effect<Value, EvalError>;
}
export type Value = number | Sexp | Primitive;
export const display = (v: Value): string =>
typeof v === "number" ? String(v)
: v._tag === "Primitive" ? `#<primitive ${v.name}>`
: show(v);A primitive returns an Effect too, so its failures are typed like everything else's. It receives each argument together with the span it came from, so when + gets a symbol, it can blame that argument and not the whole call.
The environment is an Effect service: a typed slot that the evaluator reads and the host fills.
// The environment the evaluator needs: a map from names to values.
// It's a service, so the host decides what it contains.
export class Environment extends Context.Service<
Environment,
ReadonlyMap<string, Value>
>()("Environment") {}Here is a standard environment with three operators and one constant:
const primitive = (name: string, apply: Primitive["apply"]) =>
[name, { _tag: "Primitive", name, apply } as const] as const;
const numbers = (op: string, args: ReadonlyArray<Arg>) =>
Effect.forEach(args, ({ value, span }) => {
if (typeof value === "number") return Effect.succeed(value);
const got = display(value);
return Effect.fail(new NotANumber({ op, got, span }));
});
const sum = (ns: number[]) => ns.reduce((a, b) => a + b, 0);
const product = (ns: number[]) => ns.reduce((a, b) => a * b, 1);
export const standard = new Map<string, Value>([
primitive("+", (args) => Effect.map(numbers("+", args), sum)),
primitive("*", (args) => Effect.map(numbers("*", args), product)),
primitive("/", (args) =>
Effect.gen(function* () {
const [first = 1, ...divisors] = yield* numbers("/", args);
let result = first;
for (const [i, divisor] of divisors.entries()) {
if (divisor === 0) {
const { span } = args[i + 1]!;
return yield* new DivByZero({ span });
}
result /= divisor;
}
return result;
}),
),
["pi", Math.PI],
]);Division gets its own failure. (/ 10 0) isn't malformed: it parses, and every argument is a number. Only running it reveals the problem, which is exactly the kind of error a runtime has to own.
The evaluator
With errors named and the environment supplied, the evaluator reads almost like the specification of the language:
type Ref = Extract<Expr, { _tag: "Ref" }>;
const lookup = (ref: Ref) =>
Effect.gen(function* () {
const environment = yield* Environment;
const value = environment.get(ref.name);
if (value === undefined) {
const { name: symbol, span } = ref;
return yield* new UnboundName({ symbol, span });
}
return value;
});
// The type says it all: a Value, or one of the EvalErrors, given an
// Environment.
export const evaluate = (
e: Expr,
): Effect.Effect<Value, EvalError, Environment> =>
Effect.gen(function* () {
switch (e._tag) {
case "Num":
return e.value;
case "Quote":
return e.datum;
case "Ref":
return yield* lookup(e);
case "Call": {
const fn = yield* lookup(e.op);
if (typeof fn === "number" || fn._tag !== "Primitive") {
const { name: symbol, span } = e.op;
return yield* new NotCallable({ symbol, span });
}
const args: Arg[] = [];
for (const arg of e.args) {
const value = yield* evaluate(arg);
args.push({ value, span: arg.span });
}
return yield* fn.apply(args);
}
}
});There's no throw and no try. Each yield* either produces a value or stops evaluation with a typed error. The order of evaluation is visible in the code: the operator first, then the arguments from left to right. The first failure wins:
(+ (/ 1 0) y) ; fails with DivByZero; y is never looked upDelete the NotCallable check and the code stops compiling, because fn might be a number or quoted data, and neither has an apply. The compiler now enforces part of the language's semantics.
Running a program
Parsing, evaluation, and the environment come together in run:
// Parse, evaluate, and provide the environment. What's left in the
// error channel is everything that can go wrong, by name.
export type Failure = Diagnostic | EvalError;
export const run = (
source: string,
environment: ReadonlyMap<string, Value> = standard,
): Effect.Effect<Value, Failure> =>
parse(source).pipe(
Effect.flatMap(evaluate),
Effect.provideService(Environment, environment),
);
// Parse errors and runtime errors share one display.
export const report = (source: string, error: Failure) =>
render(source, { message: error.message, span: error.span });Effect.provideService fills the Environment slot, and it disappears from the type. What's left is Effect<Value, Failure>: a value, or a parse diagnostic, or one of the four runtime errors. That's every way a program can go wrong, written down in one type.
Runtime failures carry spans, so they render exactly like Part 2's parse errors:
error: + expects numbers, got x
1 | (+ 1 (quote x))
| ^^^^^^^^^
error: division by zero
1 | (/ 10 (+ 2 -2))
| ^^^^^^^^
error: pi is not a function
1 | (* 2 (pi 3))
| ^^
error: y is not defined
1 | (+ 1 y)
| ^The division error points at (+ 2 -2), the divisor that turned out to be zero, not at the whole call. And programs that work, work:
(+ 1 2) ; => 3
(/ 10 4) ; => 2.5
(* 2 pi) ; => 6.283185307179586
+ ; => #<primitive +>The last line is new. + is now an ordinary name bound to an ordinary value, so evaluating it on its own just returns the primitive.
To try it, put Part 2's files in a directory named 02, this post's files in a sibling named 03, install effect version 4, and run this with Node 24 from inside 03:
import { Effect } from "effect";
import { display } from "./environment.ts";
import { run } from "./run.ts";
console.log(display(Effect.runSync(run("(/ 10 4)")))); // 2.5Handling some failures, not all
Because failures are values with tags, a host can handle exactly the ones it cares about and let the rest through. This host decides that dividing by zero should produce Infinity:
// This host decides that dividing by zero gives Infinity. The error
// type of `lenient` no longer mentions DivByZero.
export const lenient = (source: string) =>
run(source).pipe(
Effect.catchTag("DivByZero", () =>
Effect.succeed<Value>(Infinity),
),
);lenient("(+ 1 (/ 1 0))") succeeds with Infinity. The more interesting change is in the type. run fails with Diagnostic | EvalError, while lenient fails with Diagnostic | UnboundName | NotCallable | NotANumber. The handled case is gone from the type, so later code can't be asked to handle it again or forget the others.
Compare that with try/catch, where you catch everything, inspect it, and rethrow the rest, and the type is unknown the whole time.
That recovery happens in the host and covers the whole program. Recovery inside the language, a program catching its own errors, needs language constructs. Part 9 brings typed errors into the language itself.
Failures and defects
Typed errors are for failures the language defines. What about a bug in the interpreter? Here is a primitive that throws, the way buggy code does:
// A primitive with a bug: it throws instead of failing.
const oops: Primitive = {
_tag: "Primitive",
name: "oops",
apply: () =>
Effect.sync(() => {
throw new Error("index out of range");
}),
};
export const buggy = new Map(standard).set("oops", oops);Run (+ 1 (oops)) with this environment and Effect still catches the exception, but it doesn't call it an EvalError. It records it as a defect: the result's Cause contains a defect, index out of range, and no typed failures at all.
That distinction is the point. A failure means the program is wrong, and the user should fix their code. A defect means the interpreter is wrong, and its authors should fix theirs. Exceptions blur those two together. Effect keeps them in separate channels, so a REPL can show a friendly diagnostic for one and a bug report for the other.
The environment problem
It all works, and the remaining crack is in the third slot of the type.
The Environment is one flat map, chosen by the host before the program starts. A program can't bind a name of its own: there's no let, no function parameters, no closures. And the map mixes two different things. pi and + are part of the language's vocabulary. A future read-file would be a capability the host grants, something a sandbox might refuse. Putting both in one bag of names will cause trouble the moment programs define names and the host wants to control capabilities.
Names a program defines should resolve in lexical scopes, inside the language. Capabilities the host grants should resolve through services, outside it. Untangling those two is the next post.
Next: Part 4, The Environment Is Not the World. Names resolve in scopes. Capabilities resolve through services.
The code in this post lives in docs/snippets/blog/in-the-beginning/03 and runs in the site's test suite.