A Language for Building Languages
Part 12 of From Lists to Languages · Act III, Effects in Practice ·
The final product isn't a Lisp. It's a language-building platform.
Eleven posts built a language. This one uses it to build another one, for a job that has nothing to do with languages.
The job is vendor review. A company buys software from vendors, and each vendor has a risk score in a directory somewhere. When a vendor's score goes over a limit, someone has to approve it: security for some vendors, finance for others. The rules change often, the people who change them aren't programmers, and the rules have to be safe to run.
That's what this series has been building toward:
- People write the rules in a small language that speaks their terms.
- The language's forms are declared in the language, not in TypeScript.
- The checker says what each rule will do before it runs.
- Hosts decide what the rules may do, with Effect services.
- Editors and agents read the rules with the tools from Parts 10 and 11.
Capabilities
A policy needs two things from the world: a vendor's risk score, and a way to ask someone for an approval. Each is an Effect service, as Output has been since Part 4:
// What a host can do for a vendor policy: look up a vendor's risk
// score, and ask someone to approve a vendor. Each is an Effect
// service, so each host decides what it means.
interface DirectoryShape {
readonly risk: (vendor: string) => Effect.Effect<number>;
}
export class Directory extends Context.Service<
Directory,
DirectoryShape
>()("Directory") {}
interface ApprovalsShape {
readonly request: (
owner: string,
vendor: string,
) => Effect.Effect<void>;
}
export class Approvals extends Context.Service<
Approvals,
ApprovalsShape
>()("Approvals") {}
export interface Capabilities {
readonly directory: DirectoryShape;
readonly approvals: ApprovalsShape;
}The language gets two new values, and their types name their effects, as in Part 9:
// Part 9's values, plus two that use the capabilities. Their types
// say which capability each one needs.
export const values = ({ directory, approvals }: Capabilities) =>
new Map<string, PreludeValue>([
...standard.values,
["risk", {
scheme: {
vars: [1],
type: fun([data], num, row(["Directory"], e)),
},
value: primitive("risk", ([vendor]) =>
directory.risk(text(vendor?.value)),
),
}],
["ask", {
scheme: {
vars: [1],
type: fun([data, data], unit, row(["Approvals"], e)),
},
value: primitive("ask", ([owner, vendor]) => {
const who = text(owner?.value);
const what = text(vendor?.value);
return Effect.as(approvals.request(who, what), nil);
}),
}],
]);
// For hosts that only check. A program that needs a capability
// can't get past the checker there, so these are never called.
const missing = (what: string) => () =>
Effect.die(new Error(`no ${what} in this host`));
export const unavailable: Capabilities = {
directory: { risk: missing("Directory") },
approvals: { request: missing("Approvals") },
};risk : (Data) -> Num ! {Directory}
ask : (Data, Data) -> Unit ! {Approvals}The values are built from whatever capabilities they're given. A host that only checks programs passes unavailable. That's safe because of Part 9: a program that would call risk has Directory in its type, and a host that doesn't allow Directory rejects the program before it starts.
Forms in the language
Part 7's macros were written in the language, and the checker couldn't speak for them. Part 8's forms brought their own rules, but they were TypeScript functions. Part 10 described forms as data, but separately from their rules. A declared form is all three at once. It's written in the language, it states its rule as data, and the kernel enforces that rule:
(defform (review (owner Name) (vendor Name))
(doc Ask owner to approve vendor.)
`(ask ',owner ',vendor))
(defform (vendor (name Name) (limit Num) (owner Name))
(doc Have owner review name when its risk is over limit.)
`(when (< ,limit (risk ',name))
(review ,owner ,name)))A slot is either a Name, a bare symbol like acme, or an expression with a type. The template says what the form means, using forms and values the language already has. Inside the template, ,name is the symbol the user wrote, so ',name is that symbol as data. vendor's template uses review, which is declared just above it.
A declaration is data with a schema:
// A form declared in the language: its name, its slots, a summary,
// and a template. A slot is a Name (a bare symbol, like acme) or an
// expression of type Num or Data.
export const Slot = Schema.Struct({
name: Schema.String,
kind: Schema.Literals(["Name", "Num", "Data"]),
});
export type Slot = typeof Slot.Type;
export const Declared = Schema.Struct({
name: Schema.String,
slots: Schema.Array(Slot),
summary: Schema.String,
body: Sexp,
});
export type Declared = typeof Declared.Type;Reading one checks its shape, and that every hole names a slot:
// (defform (name (slot Kind) ...) (doc word ...) `template)
// The reader has no strings, so a doc is a list of words.
export const declare = (form: Sexp) =>
Effect.gen(function* () {
const usage =
"expected (defform (name slot ...) (doc ...) `body)";
const [keyword, head, doc, template, ...extra] =
form._tag === "List" ? form.items : [];
if (
keyword?._tag !== "Sym" || keyword.name !== "defform" ||
head?._tag !== "List" || headName(doc) !== "doc" ||
headName(template) !== "quasiquote" || extra.length > 0
) {
return yield* fail(usage, form.span);
}
const [name, ...rest] = head.items;
if (name?._tag !== "Sym") {
const message = "a form's name must be a symbol";
return yield* fail(message, head.span);
}
const slots: Slot[] = [];
for (const item of rest) {
const [slot, kind, ...more] =
item._tag === "List" ? item.items : [];
const known = kinds.find(
(k) => kind?._tag === "Sym" && kind.name === k,
);
if (slot?._tag !== "Sym" || !known || more.length > 0) {
const message = "a slot is (name Name), (name Num), or "
+ "(name Data)";
return yield* fail(message, item.span);
}
slots.push({ name: slot.name, kind: known });
}
const body = (template as List).items[1]!;
yield* holes(body, slots);
const words = (doc as List).items.slice(1).map(show);
const summary = words.join(" ");
return { name: name.name, slots, summary, body } as Declared;
});
// Every hole is ,slot for one of the form's slots.
const holes = (
x: Sexp,
slots: ReadonlyArray<Slot>,
): Effect.Effect<void, Diagnostic> => {
if (x._tag !== "List") return Effect.void;
if (headName(x) === "unquote") {
const [, slot, ...extra] = x.items;
const known = slots.some(
(s) => slot?._tag === "Sym" && s.name === slot.name,
);
return known && extra.length === 0
? Effect.void
: fail("a hole is , and the name of a slot", x.span);
}
const name = headName(x);
if (name === "unquote-splicing" || name === "quasiquote") {
const message = `${name} isn't allowed in a form's template`;
return fail(message, x.span);
}
return Effect.forEach(x.items, (i) => holes(i, slots), {
discard: true,
});
};Two audiences, two places
Part 7 ended on the Lisp Curse in a small form. A macro's mistakes and its user's mistakes were reported in the same place, inside the expansion, where neither the macro's author nor its user had written anything. A declared form has two audiences, and each gets its own errors.
The form's author gets errors in the template, when the form is loaded. Each hole is filled with a stand-in of its kind, and the template is checked once, before any program uses it:
// Check a declared form once, when it's loaded. Its holes are
// filled with stand-ins of their kinds, so a mistake in the
// template is reported in the template, to the form's author.
export const verify = (prelude: Prelude, d: Declared) => {
const standIn = ({ name, kind }: Slot, { span }: Sexp): Sexp =>
kind === "Num" ? { _tag: "Num", value: 0, span }
: kind === "Name" ? { _tag: "Sym", name, span }
: quoted({ _tag: "Sym", name, span });
const body = fill(d.body, d.slots, standIn);
return Effect.asVoid(elaborate(prelude, body, null));
};Forget the quote in (risk ',name), and loading vendor.lisp fails there:
error: name is not defined
7 | `(when (< ,limit (risk ,name))
| ^^^^^Misspell a hole, and that fails too:
error: a hole is , and the name of a slot
7 | `(when (< ,limt (risk ',name))
| ^^^^^The form's user gets errors at the call. The call is checked against the declaration before the template is used:
// A declared form, as a Part 8 form. The call is checked against
// the declaration first, so its errors point at the call: the
// wrong number of parts, a slot that isn't a name, or an argument
// of the wrong type.
export const toForm = (d: Declared): Form => (call, k) =>
Effect.gen(function* () {
const args = call.items.slice(1);
if (args.length !== d.slots.length) {
const names = d.slots.map((s) => s.name);
const message = `${d.name} takes ${listed(names)}`;
return yield* fail(message, call.span);
}
const filled = new Map<string, Sexp>();
const params: string[] = [];
const values: Core[] = [];
for (const [i, slot] of d.slots.entries()) {
const arg = args[i]!;
if (slot.kind === "Name") {
if (arg._tag !== "Sym") {
const message = `${d.name}'s ${slot.name} must be a name`;
return yield* fail(message, arg.span);
}
filled.set(slot.name, arg);
continue;
}
// Bind the value once, to a name no program can write.
const type = slot.kind === "Num" ? num : data;
values.push(yield* k.check(arg, type));
params.push(`#${slot.name}`);
const name = `#${slot.name}`;
filled.set(slot.name, { _tag: "Sym", name, span: arg.span });
}
yield* unshadowed(d, k, call.span);
const value = (s: Slot) => filled.get(s.name)!;
const body = fill(d.body, d.slots, value, call.span);
const schemes = values.map((v) => ({ vars: [], type: v.type }));
const inside = (k: Kernel) => k.elaborate(body);
const fn = yield* now(k, params, schemes, inside, call.span);
return yield* k.apply(fn, values, call.span);
});
const listed = (names: ReadonlyArray<string>) =>
names.length < 2 ? names.join("")
: `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;error: expected Num, got Data
1 | (vendor acme 'high security)
| ^^^^^error: vendor takes name, limit, and owner
1 | (vendor acme 70)
| ^^^^^^^^^^^^^^^^error: vendor's name must be a name
1 | (vendor 'acme 70 security)
| ^^^^^Every node the template contributes gets the call's span, which is what fill's at is for:
// Fill each hole with value(slot, hole). With `at`, every node the
// template contributes takes that span instead of its own, so
// errors inside it point at the call that used the form.
const fill = (
x: Sexp,
slots: ReadonlyArray<Slot>,
value: (slot: Slot, hole: Sexp) => Sexp,
at?: Span,
): Sexp => {
const span = at ?? x.span;
if (x._tag !== "List") return { ...x, span };
if (headName(x) === "unquote") {
const name = (x.items[1] as { name: string }).name;
return value(slots.find((s) => s.name === name)!, x);
}
const items = x.items.map((i) => fill(i, slots, value, at));
return { ...x, items, span };
};So anything that goes wrong inside a template points at the call that used the form, never into vendor.lisp, a file the user may never have seen.
Templates are hygienic in both directions. Values from the user's arguments are bound to names like #limit, which no program can write, so a template can't capture them. And the names a template uses mean what they mean in the prelude. If a local at the call hides one, the call is refused instead of quietly meaning something else:
// The names a template uses mean what they mean in the prelude. If
// a local at the call hides one, the call is refused instead of
// quietly meaning something else.
const unshadowed = (d: Declared, k: Kernel, span: Span) =>
Effect.gen(function* () {
for (const name of free(d.body)) {
const sym = { _tag: "Sym", name, span } as const;
const here = yield* Effect.result(k.elaborate(sym));
// A form's name that no local hides: not usable as a value.
if (Result.isFailure(here)) continue;
const there = yield* Effect.result(k.prelude(name, span));
const a = here.success;
const b = Result.isSuccess(there) ? there.success : undefined;
const same = a._tag === "Local" && b?._tag === "Local" &&
a.depth === b.depth && a.slot === b.slot;
if (!same) {
const used = `the ${name} ${d.name} uses`;
return yield* fail(`a local ${name} hides ${used}`, span);
}
}
});
// The symbols a template refers to: not quoted, not holes, and not
// bound by a lambda inside the template.
const free = (body: Sexp) => {
const found = new Set<string>();
const walk = (x: Sexp, bound: ReadonlySet<string>): void => {
if (x._tag === "Sym" && !bound.has(x.name)) found.add(x.name);
if (x._tag !== "List") return;
const name = headName(x);
if (name === "quote" || name === "unquote") return;
const params = x.items[1];
if (name === "lambda" && params?._tag === "List") {
const inner = new Set(bound);
for (const p of params.items) {
if (p._tag === "Sym") inner.add(p.name);
}
return x.items.slice(2).forEach((i) => walk(i, inner));
}
x.items.forEach((i) => walk(i, bound));
};
walk(body, new Set());
return found;
};error: a local risk hides the risk vendor uses
1 | ((lambda (risk) (vendor acme 70 security)) 5)
| ^^^^^^^^^^^^^^^^^^^^^^^^^That's the other half of hygiene that Part 7 left out, done by refusing instead of renaming. Renaming would need the scope machinery that Part 7 pointed to.
Loading a language
A prelude file is read form by form. Each form is declared, verified against the forms above it, and added:
// Load a prelude written in the language. Each defform is read,
// checked, and added in order, so a form can use the ones above it.
export const load = (
base: Prelude,
baseDocs: Docs,
source: string,
) =>
Effect.gen(function* () {
let prelude = base;
let docs = baseDocs;
for (const form of yield* readAll(source)) {
const declared = yield* declare(form);
yield* verify(prelude, declared);
const forms = new Map(prelude.forms);
forms.set(declared.name, toForm(declared));
prelude = { ...prelude, forms };
docs = { ...docs, [declared.name]: toDoc(declared) };
}
return { prelude, docs };
});// The vendor language: Part 9's forms, the capability values, and
// the forms in vendor.lisp.
export const vendorSource = readFileSync(
new URL("./vendor.lisp", import.meta.url),
"utf8",
);
export const vendorLanguage = (capabilities: Capabilities) =>
load(
{ forms: standard.forms, values: values(capabilities) },
standardDocs,
vendorSource,
);The base is Part 9's language: its forms, its values, and the two new ones. Part 10's form descriptions come from the declarations:
// Part 10's description of a form, read off the declaration.
export const toDoc = (d: Declared): FormDoc => {
const slots = d.slots.map((s) => `(${s.name} ${s.kind})`);
return {
syntax: `(${[d.name, ...slots].join(" ")})`,
summary: d.summary,
head: 1,
examples: [],
};
};A policy
Here's a policy, written by someone who knows the vendors and not the compiler:
(do
(vendor acme 70 security)
(vendor globex 90 finance)
(vendor initech 50 security))Before it runs, its type says what it will do:
Unit ! {Approvals, Directory}It returns nothing useful, it looks things up in the directory, and it may ask for approvals. That's the whole contract between the policy and whatever runs it.
Hosts get their capabilities from Effect services:
// Editors and agents check policies but run nothing, so they need
// no capabilities.
export const typeOfPolicy = (source: string) =>
Effect.gen(function* () {
const { prelude } = yield* vendorLanguage(unavailable);
const { core, effects } = yield* compile(prelude, source);
return showComputation(core.type, effects);
});
// A host that runs a policy gets its capabilities from Effect
// services, and names the effects it allows. Its type says what
// it needs: Directory, Approvals, and Output.
export const runPolicy = (
source: string,
allowed: ReadonlyArray<string>,
) =>
Effect.gen(function* () {
const directory = yield* Directory;
const approvals = yield* Approvals;
const capabilities = { directory, approvals };
const { prelude } = yield* vendorLanguage(capabilities);
return yield* run(prelude, source, allowed);
});
// A dry run: a directory that takes a moment to answer, and an
// approvals service that writes requests down instead of sending
// them. Unknown vendors count as the highest risk.
export const dryRun = (
source: string,
scores: ReadonlyMap<string, number>,
) =>
Effect.gen(function* () {
const requested: string[] = [];
yield* runPolicy(source, ["Directory", "Approvals"]).pipe(
Effect.provideService(Directory, {
risk: (vendor) =>
Effect.as(Effect.sleep(10), scores.get(vendor) ?? 100),
}),
Effect.provideService(Approvals, {
request: (owner, vendor) =>
Effect.sync(() => {
requested.push(`${owner} reviews ${vendor}`);
}),
}),
Effect.provideService(Output, { print: () => Effect.void }),
);
return requested;
});The dry run's directory knows that acme scores 80 and globex scores 40, and that initech is unknown, which counts as the highest risk. Each lookup waits a moment, as a real directory would, so the dry run runs with Effect.runPromise. It returns the approvals the policy would request:
security reviews acme
security reviews initechAn auditor's host might allow lookups but not approvals. The same policy is rejected there before it does anything, and the error points at the first rule that would ask:
error: unhandled effect: Approvals
2 | (vendor acme 70 security)
| ^^^^^^^^^^^^^^^^^^^^^^^^^A production host would provide a Directory that calls a real API and an Approvals service that opens tickets. The policy, the language, and the checker wouldn't change.
Tools for free
Nothing in Parts 10 and 11 knows about vendors, and nothing needs to. The documentation generator reads the new language and writes:
### `(review (owner Name) (vendor Name))`
Ask owner to approve vendor.
### `(vendor (name Name) (limit Num) (owner Name))`
Have owner review name when its risk is over limit.
...
- `risk : (Data) -> Num ! {Directory}`
- `ask : (Data, Data) -> Unit ! {Approvals}`Part 11's agent tools work on it unchanged, given the new prelude. An agent that reads the policy gets the type Unit ! {Approvals, Directory} in its report. If it tries to run (vendor acme 70 security) in the REPL, the answer is unhandled effect: Directory, and nothing runs. The REPL is a host too, and it was never given a directory.
The Lisp Curse, revisited
The series started with a tension. Lisp makes it easy to invent abstractions, and hard for other people and tools to understand them. Here's where each part of the answer came from:
| Piece | From | What it gives a new abstraction |
|---|---|---|
| Trees | Lisp, Parts 1 and 7 | Syntax that programs can read and write |
| Schemas | Effect Schema, Parts 2, 6, 10, and 12 | Shapes that tools can decode and check |
| Types | Parts 5 and 8 | A rule, checked where the abstraction is used |
| Effects | Effect, Parts 3, 4, and 9 | A statement of what running it will need |
| Forms | Parts 8 and 12 | Errors in the user's terms, at the user's code |
| Tools | Parts 10 and 11 | Docs, analysis, and edits that don't guess |
vendor is as cheap to invent as a macro: four lines of Lisp. Unlike a macro, it comes with a rule the checker enforces, documentation a tool can generate, and a type that tells a host what it needs. Other people, and other programs, can understand it without reading its template.
What it took
The whole system, everything the final post imports, from reader to agent tools, is about 3,100 lines of TypeScript, not counting comments and blank lines. Some of that is earlier parts' code that the last parts no longer call.
The three ideas did most of the work. Part 2's reader is 67 lines, because Lisp has no grammar to parse. Every representation (syntax, core, types, diagnostics, reports, form declarations) got validation and JSON for free from its schema. Concurrency, interruption, typed errors, and services came from Effect: race is Effect.race, catch is Effect.catchTag, and every capability is a service.
Forma's engine is about 55,000 lines, roughly eighteen times more. The difference is the engineering a teaching language skips: modules and packages, more than one error per run, an editor and a language server, good messages when inference fails, a faster evaluator, records and strings and the rest of real syntax, and emitting TypeScript that runs without the interpreter.
Forma
This series is Forma's origin story. Here's the vendor example in Forma itself, with records, keyword unions, options, and pattern matching:
(type Risk (Union :low :high))
(type Vendor {:name String :risk Risk :reviewed-on (Option Int)})
(: reviewed? (-> Vendor Bool))
(define reviewed? [vendor]
(match vendor.reviewed-on
(Some day) (>= day 0)
None false))
(: acme Vendor)
(define acme {:name "Acme" :risk :high :reviewed-on 900})
(reviewed? acme)It type-checks as Bool and evaluates to true. Change :risk :high to :risk :medium and the checker reports :medium is not one of Union<:low, :high>. Leave out the None case and it warns Non-exhaustive match: missing constructor(s) None.
Forma's forms are declared in the language too, with typed holes, checks whose diagnostics point at the author's source, and output whose shape is a schema; see Writing a form. The language guide and roadmap describe what Forma does today, and what it doesn't yet.
Where it ends up
Lisp gives us the freedom to invent languages. Schema gives those languages form. Effect gives them consequences.
Together, they let us build languages that can explain themselves.
To try it, put Parts 2 through 11 in directories named 02 to 11, this post's files in 12, install effect version 4, and run this with Node 24 from inside 12:
import { Effect } from "effect";
import { typeOfPolicy } from "./language.ts";
const policy = "(vendor acme 70 security)";
console.log(Effect.runSync(typeOfPolicy(policy)));
// Unit ! {Approvals, Directory}The code in this post lives in docs/snippets/blog/in-the-beginning/12 and runs in the site's test suite.