Effects Are Part of the Type
Part 09 of From Lists to Languages · Act III, Effects in Practice ·
The type describes not only the result, but what the computation demands.
Part 8 ended on a type that doesn't tell the truth. print has the type ('a) -> 'a, the same as the identity function. The checker knows that (print 1) is a number, and nothing about what happens when it runs.
The interpreter itself has known the difference since Part 4. Its TypeScript type is Effect<Value, Failure, Output>: what it returns, how it can fail, and which services it needs. Effect puts all three in the type of every computation. The language we're building puts only the first in the type of its expressions.
This post closes that gap. The language gets three effects: Output for printing, DivByZero for the one runtime error left, and Async for waiting. Each one becomes part of the type, and a host can read a program's type to learn what running it will demand.
A row of effects
A function type now has a fourth part: what calling the function does.
// Part 5's types, with two additions. A function type now says what
// calling it does, as a row of effect labels. And Unit is the type
// of an expression that only matters for what it does.
export const TUnit = Schema.TaggedStruct("TUnit", {});
export interface TFun {
readonly _tag: "TFun";
readonly params: ReadonlyArray<Type>;
readonly rest: Type | null;
readonly result: Type;
readonly effects: Type;
}
// A row: some labels, then maybe a variable standing for more.
export interface TRow {
readonly _tag: "TRow";
readonly labels: ReadonlyArray<string>;
readonly tail: Type | null;
}
export type Type =
| typeof TNum.Type
| typeof TData.Type
| typeof TUnit.Type
| typeof TVar.Type
| TFun
| TRow;
const LazyType = Schema.suspend((): Schema.Codec<Type> => Type);
export const TFun = Schema.TaggedStruct("TFun", {
params: Schema.Array(LazyType),
rest: Schema.NullOr(LazyType),
result: LazyType,
effects: LazyType,
});
export const TRow = Schema.TaggedStruct("TRow", {
labels: Schema.Array(Schema.String),
tail: Schema.NullOr(LazyType),
});
export const Type = Schema.Union([
TNum,
TData,
TUnit,
TVar,
TFun,
TRow,
]);That part is a row: a set of effect labels, plus maybe a variable that stands for "and whatever else". Types are printed with the effects after a !:
print : ('a) -> 'a ! {Output}
/ : (Num...) -> Num ! {DivByZero}
sleep : (Num) -> Unit ! {Async}
+ : (Num...) -> Numsleep returns Unit, the type of an expression that only matters for what it does. Its one value is the empty list.
The prelude writes every built-in's effects as an open row:
// Every function's effects are open: {Output | 'e} means it prints,
// and its caller may also be doing anything else.
const a: Type = { _tag: "TVar", id: 0 };
const e: Type = { _tag: "TVar", id: 1 };
const does = (...labels: string[]) => row(labels, e);
const thunk = (effects: Type) => fun([], a, effects);
const types = new Map<string, Type>([
["+", fun([], num, does(), num)],
["*", fun([], num, does(), num)],
["/", fun([], num, does("DivByZero"), num)],
["<", fun([num, num], num, does())],
["print", fun([a], a, does("Output"))],
["sleep", fun([num], unit, does("Async"))],
["choose", fun([num, a, a], a, does())],
// Handling DivByZero takes it out of the row.
["recover", fun([thunk(row(["DivByZero"], e)), thunk(e)], a, e)],
["fastest", fun([thunk(e), thunk(e)], a, does("Async"))],
]);{Output | 'e} means "prints, and the code calling it may also do anything else". The 'e is what lets a call to print fit inside a function that also divides. It's left out when a type is printed, unless it appears twice and so says something.
Unifying rows
The solver from Part 5 makes two types equal or explains why it can't. Two function types are now equal only if their effects are, too. Rows are compared as sets:
// Two rows are equal as sets of labels. Labels one row has and
// the other lacks must fit in the other's tail. A row without a
// tail is closed: nothing more fits, so the extra label is an
// unhandled effect.
const rows = (a: TRow, b: TRow, span: Span): Check<void> =>
Effect.gen(function* () {
const onlyA = a.labels.filter((l) => !b.labels.includes(l));
const onlyB = b.labels.filter((l) => !a.labels.includes(l));
if (!b.tail && onlyA[0]) {
return yield* new Unhandled({ effect: onlyA[0], span });
}
if (!a.tail && onlyB[0]) {
return yield* new Unhandled({ effect: onlyB[0], span });
}
if (!a.tail || !b.tail) {
if (a.tail) yield* unify(a.tail, row(onlyB, null), span);
if (b.tail) yield* unify(b.tail, row(onlyA, null), span);
return;
}
if (sameVar(a.tail, b.tail)) {
if (onlyA.length + onlyB.length === 0) return;
return yield* mismatch(a, b, span);
}
// {A | r1} = {B | r2}: r1 gets B's extras, r2 gets A's,
// and both keep a new tail for anything else.
const rest = fresh();
yield* unify(a.tail, row(onlyB, rest), span);
yield* unify(b.tail, row(onlyA, rest), span);
});Labels that one row has and the other lacks must fit in the other's tail. If that tail is an unknown, it learns the extra labels, and a new unknown takes its place for anything still to come. If there's no tail, the row is closed and nothing more fits. That's a new type error:
// A new type error: an effect that nothing around it allows.
export class Unhandled extends Schema.TaggedError<Unhandled>()(
"Unhandled",
{ effect: Schema.String, span: Span },
) {
override get message() {
return `unhandled effect: ${this.effect}`;
}
}
export type CheckError = Part5Error | Unhandled;Where effects happen
Effects happen when a function is called, so the kernel's call rule gets one more step. Every frame records what the code in it may do, and a call's effects must fit there:
// Part 6's rule for calls, plus one step at the end: a call
// does what the function's type says, and the frame it's in
// must allow that.
const apply = (
fn: Core,
args: Core[],
span: Span,
frame: Frame,
): Elab =>
Effect.gen(function* () {
const f = resolve(fn.type);
if (f._tag === "TVar") {
const type = fresh();
const types = args.map((arg) => arg.type);
const called = fun(types, type, frame.effects);
yield* unify(f, called, fn.span);
return { _tag: "Call", fn, args, type, span };
}
if (f._tag !== "TFun") {
const expected = "a function";
const got = showType(f);
const { span } = fn;
return yield* new Mismatch({ expected, got, span });
}
const params = paramsFor(f, args.length);
if (!params) {
const expected = f.params.length;
const got = args.length;
return yield* new ArityError({ expected, got, span });
}
for (const [i, arg] of args.entries()) {
yield* unify(params[i]!, arg.type, arg.span);
}
yield* unify(f.effects, frame.effects, span);
return { _tag: "Call", fn, args, type: f.result, span };
});A function's body doesn't run when the function is made. So k.lambda gives the body a fresh row of its own, and that row becomes the function's effects instead of the surrounding code's:
const kernel = (frame: Frame): Kernel => ({
elaborate: (x) => go(x, frame),
check: (x, type) =>
Effect.tap(go(x, frame), (c) =>
unify(type, c.type, x.span),
),
// Code in a new frame that runs right here, like a let's
// body, may do what the code around it may do.
within: (names, schemes, inside) => {
const { effects } = frame;
const inner = { names, schemes, effects, parent: frame };
return inside(kernel(inner));
},
// A function's body runs later: it gets a fresh row of
// effects, and that row becomes part of the function's type.
lambda: (names, schemes, inside, span) =>
Effect.gen(function* () {
const effects = fresh();
const inner = { names, schemes, effects, parent: frame };
const body = yield* inside(kernel(inner));
const params = schemes.map((s) => s.type);
const type = fun(params, body.type, effects);
const lambda = { _tag: "Lambda", params: names } as const;
return { ...lambda, body, type, span };
}),
prelude: (name, span) => {
let depth = 0;
for (let f = frame.parent; f; f = f.parent) depth++;
return ref(name, span, root, depth);
},
apply: (fn, args, span) => apply(fn, args, span, frame),
fresh,
generalize: (type) => generalize(type, frame),
effects: frame.effects,
});The difference shows up in the types. Calling print prints, and making a function that will print doesn't:
(print 1) ; : Num ! {Output}
(lambda (x) (print x)) ; : ('a) -> 'a ! {Output}The second expression does nothing yet. Its effect is latent: it's in the function's type, waiting for a call.
Part 8's let generalizes, so the solver has to be careful which unknowns it hands out. An unknown in the effects of an enclosing frame belongs to the code around the let, just like an unknown in an enclosing parameter's type, so generalize keeps those too.
Effect polymorphism
Here's Part 7's twice again, as a function:
(let ((twice (lambda (f x) (f (f x)))))
twice)
; : (('a) -> 'a ! 'b, 'a) -> 'a ! 'bNobody wrote that type. It says that twice does exactly what f does, whatever that is. The variable 'b is printed because it appears twice: it links f's effects to twice's. In the same let, pass print and the call prints; pass arithmetic and it's pure:
(twice print 1) ; : Num ! {Output}
(twice (lambda (n) (* n 3)) 1) ; : NumA language without effect variables would have to choose: either every higher-order function is assumed to do everything, or twice can't accept a function that prints.
Unit, do, and when
Part 8 left when out, because "does nothing when the test fails" had no type. Now it does. Forms that run code right where it's written, like a let's body or an if's branches, use a helper that gives the new function the surrounding code's effects:
// A function that the form calls right where it's written. Its body
// may do what the code around it may do, so an effect it isn't
// allowed is reported inside the body, where it happens.
export const now = (
k: Kernel,
names: ReadonlyArray<string>,
schemes: ReadonlyArray<Scheme>,
inside: Inside,
span: Span,
): Elab =>
Effect.map(k.within(names, schemes, inside), (body): Core => {
const params = schemes.map((s) => s.type);
const type = fun(params, body.type, k.effects);
return { _tag: "Lambda", params: names, body, type, span };
});That keeps diagnostics in place. When an effect isn't allowed, the error points at the call inside the branch that does it, not at the whole if.
do runs expressions in order:
// Run `first` for what it does, then `next`. It's the same core as
// ((lambda (#do) next) first), and no program can name #do.
const then = (k: Kernel, first: Core, next: Inside, span: Span) => {
const scheme = { vars: [], type: first.type };
const fn = now(k, ["#do"], [scheme], next, span);
return Effect.flatMap(fn, (fn) => k.apply(fn, [first], span));
};
// (do a b ... z) runs each in order. Its value is z's.
const do_: Form = ({ items: [, ...exprs], span }, k) =>
exprs.length === 0
? fail("do needs at least one expression", span)
: steps(exprs, k, span);
const steps = (
exprs: ReadonlyArray<Sexp>,
k: Kernel,
span: Span,
): Elab =>
Effect.gen(function* () {
const [first, ...rest] = exprs;
const value = yield* k.elaborate(first!);
if (rest.length === 0) return value;
return yield* then(k, value, (k) => steps(rest, k, span), span);
});And when is a branch whose value is always unit:
// (when test body) runs body unless test is 0. Either way its value
// is unit, the empty list, of type Unit.
const nothing = (span: Span): Core => {
const datum = { _tag: "List", items: [], span } as const;
return { _tag: "Datum", datum, type: unit, span };
};
const when: Form = ({ items, span }, k) =>
Effect.gen(function* () {
const [, test, body, ...extra] = items;
if (!test || !body || extra.length > 0) {
return yield* fail("when takes a test and a body", span);
}
const checked = yield* k.check(test, num);
const done = () => Effect.succeed(nothing(span));
return yield* branch(
k,
checked,
(k) =>
Effect.flatMap(k.elaborate(body), (value) =>
then(k, value, done, span),
),
done,
span,
);
});(when (< 1 2) (print 1)) ; : Unit ! {Output}Unit is a type like Num, so using a when where a number is needed is an ordinary type error:
error: expected Num, got Unit
1 | (+ 1 (when 1 2))
| ^^^^^^^^^^Typed errors
/ can fail, and its type says so. Handling the failure should take it out of the type, the way Effect.catchTag takes an error out of an Effect's error channel. catch and race both hand their parts to a prelude function as functions with no parameters, so the prelude function decides when, and whether, each part runs:
// (catch body fallback) and (race a b) pass their parts to a
// prelude function as functions with no parameters. The prelude
// function decides when each part runs, and whether it runs at all.
const delaying =
(name: string, usage: string): Form =>
({ items: [, ...parts], span }, k) =>
Effect.gen(function* () {
if (parts.length !== 2) return yield* fail(usage, span);
const thunks: Core[] = [];
for (const part of parts) {
const inside = (k: Kernel) => k.elaborate(part);
thunks.push(yield* k.lambda([], [], inside, part.span));
}
return yield* call(k, name, thunks, span);
});The handler's whole contract is in its type:
recover : (() -> 'a ! {DivByZero | 'b}, () -> 'a ! 'b) -> 'a ! 'bThe body may divide, and may do anything else ('b). The fallback may do the same anything else, and so may the whole call. DivByZero is in the first row and in no other: that's what handling it means.
(catch (/ 1 0) 0) ; : Num, => 0
(catch (print (/ 1 0)) 0) ; : Num ! {Output}At runtime, recover is Effect.catchTag:
// Call a function with no parameters, from inside a primitive.
const force = (f: Value | undefined) =>
typeof f === "object" && f._tag === "Closure"
? execute(f.body, { slots: [], parent: f.env })
: typeof f === "object" && f._tag === "Primitive" ? f.apply([])
: Effect.die(new Error("ill-typed core: not a function"));
const nil: Value = {
_tag: "List",
items: [],
span: { start: 0, end: 0 },
};
const added = new Map<string, Primitive["apply"]>([
["<", ([x, y]) =>
Effect.succeed(Number(x?.value) < Number(y?.value) ? 1 : 0)],
["choose", ([test, yes, no]) =>
Effect.succeed((test?.value === 0 ? no : yes)!.value)],
["sleep", ([ms]) =>
Effect.as(Effect.sleep(Number(ms?.value)), nil)],
// A typed error, caught by tag.
["recover", ([tried, caught]) =>
Effect.catchTag(force(tried?.value), "DivByZero", () =>
force(caught?.value),
)],
// Run both at once. The first to succeed wins, and the other
// is interrupted.
["fastest", ([x, y]) =>
Effect.race(force(x?.value), force(y?.value))],
]);Effects compose through user functions, too. A function that calls its argument inside catch is safe whatever the argument divides by:
(lambda (f) (catch (f 1) 0))
; : ((Num) -> Num ! {DivByZero | 'a}) -> Num ! 'aAsynchronous work
sleep waits, and race runs two computations at once. The winner's value is the race's value, and Effect interrupts the loser:
fastest : (() -> 'a ! 'b, () -> 'a ! 'b) -> 'a ! {Async | 'b}(race (do (sleep 50) (print 1))
(do (sleep 5) (print 2)))
; : Num ! {Async, Output}
; prints 2 and nothing else, => 2The slow side is interrupted while it's asleep, so its print never runs. Concurrency and interruption are the runtime's job, and Effect already does both. The language only had to say, in a type, that this expression needs them.
The host decides
So far the whole program's effects are whatever it turns out to do. A host can also say what it allows:
// What the whole program may do: whatever it turns out to do,
// or, when a host says which effects it allows, only those.
const root: Frame = {
names: [...prelude.values.keys()],
schemes: [...prelude.values.values()].map((v) => v.scheme),
effects: allowed ? row(allowed, null) : fresh(),
parent: undefined,
};That's a closed row at the root. Every call a program makes has to fit in it, so a program that needs anything else is rejected before it runs:
// Part 8's pipeline. A host may name the effects it allows; then
// a program that needs any other is rejected before it starts.
export const compile = (
prelude: Prelude,
source: string,
allowed: ReadonlyArray<string> | null = null,
) =>
expanded(source).pipe(
Effect.flatMap((sexp) => elaborate(prelude, sexp, allowed)),
);
// Effects are erased, and Part 6's executor runs what's left.
export const run = (
prelude: Prelude,
source: string,
allowed: ReadonlyArray<string> | null = null,
) =>
compile(prelude, source, allowed).pipe(
Effect.flatMap(({ core }) =>
execute(erase(core), rootEnv(prelude)),
),
);
// What a program returns, and what running it does.
export const typeOf = (source: string) =>
compile(standard, source).pipe(
Effect.map(({ core, effects }) =>
showComputation(core.type, effects),
),
);With nothing allowed, (print 1) is rejected at the call:
error: unhandled effect: Output
1 | (print 1)
| ^^^^^^^^^(lambda (x) (print x)) is accepted, because making the function doesn't print. Calling it does, and the error points at the call that would:
error: unhandled effect: Output
1 | (let ((p (lambda (x) (print x)))) (+ 1 (p 2)))
| ^^^^^This is where the language's effects meet Effect's. Each label stands for one part of the Effect the executor builds:
Outputis a service in the requirements,R. The host has to provide it.DivByZerois a typed error in the error channel,E. The host has to handle it or report it.Asyncsays the computation may suspend. The host needsEffect.runPromise;Effect.runSyncthrows on an asynchronous effect.
Here is a host that runs scripts to completion, right now:
// A host that runs scripts to completion, right now. It has no
// event loop to wait on, so it allows Output and DivByZero but not
// Async. The checker's answer is what makes Effect.runSync safe.
export const script = (
source: string,
output: { readonly print: (line: string) => Effect.Effect<void> },
) =>
Effect.runSyncExit(
run(standard, source, ["Output", "DivByZero"]).pipe(
Effect.provideService(Output, output),
),
);(do (print 1) 2) prints 1 and returns 2. A script that sleeps never starts:
error: unhandled effect: Async
1 | (do (print 1) (sleep 10))
| ^^^^^^^^^^The checker's answer is what makes runSync safe here. A program that type-checks with no Async in its row doesn't suspend.
Erasure
Effects are a fact about checking. The executor never reads a type, so once a program has been checked, its effects can go:
// The executor never reads a type, so once checking is done the
// effects can go. What's left is Part 6's core. Unit becomes Data:
// at runtime, unit is the empty list.
const eraseType = (t: Type): Part5.Type => {
switch (t._tag) {
case "TFun": {
const params = t.params.map(eraseType);
const rest = t.rest && eraseType(t.rest);
return Part5.fun(params, eraseType(t.result), rest);
}
case "TUnit":
case "TRow":
return Part5.data;
default:
return t;
}
};
export const erase = (c: Core): Core6 => {
const type = eraseType(c.type);
switch (c._tag) {
case "Lambda":
return { ...c, type, body: erase(c.body) };
case "Call": {
const args = c.args.map(erase);
return { ...c, type, fn: erase(c.fn), args };
}
default:
return { ...c, type };
}
};What's left is Part 6's core, and Part 6's executor runs it. print is still the primitive that asks for the Output service, and / still fails with DivByZero. The only new runtime code is in the three primitives that wrap Effect: sleep, recover, and fastest.
There's a cost to that, and it's worth naming. The TypeScript type of run is the same for every program: it always says Output might be needed and DivByZero might happen. The language's checker knows better for each program, but TypeScript can't see its answer. A host has to trust the checker, as script does.
The effect vocabulary is also fixed. The prelude names three effects, and catch handles exactly one of them. A language where programs declare their own effects and handlers, as in Koka or OCaml 5, needs the same rows with more forms around them.
This post's prelude keeps Part 8's quote, lambda, let, and if, adds do, when, catch, and race, and leaves match out to stay short.
To try it, put Parts 2 through 8 in directories named 02 to 08, this post's files in 09, install effect version 4, and run this with Node 24 from inside 09:
import { Effect } from "effect";
import { typeOf } from "./run.ts";
const twice = "(let ((twice (lambda (f x) (f (f x))))) twice)";
console.log(Effect.runSync(typeOf(twice)));
// (('a) -> 'a ! 'b, 'a) -> 'a ! 'bWhat's next
A program's type now says what it returns, how it can fail, and what it needs from its host. That's three questions answered without running anything.
Every answer so far has come from the checker, though. The next post asks what else can be built over the same typed representation: a formatter, an analyzer, and documentation, each another interpretation of one tree.
Next: Part 10, One Program, Many Interpretations. Programs become things we can reason about, not just execute.
The code in this post lives in docs/snippets/blog/in-the-beginning/09 and runs in the site's test suite.