Equana

All articles
July 12, 2026·Max Schuriglanguage-designparsingequana

Designing a programming language that reads like English — for scientific computing

Why Equana lets you write "let f be x mapsto x^2", how the parser handles optional syntax without backtracking, and where readable becomes footgun.

I've spent the last while building Equana, a scientific-computing notebook that runs entirely in the browser — no install, no server-side execution, your data never leaves the device. It's in alpha. This post isn't about the notebook, though. It's about the part of the project I agonized over most: the language itself, and specifically the decision to let it optionally read like English.

This is the tagline example, and it's real, runnable code:

let f be x mapsto x^2

Read it out loud: "let f be x maps-to x squared." That's also exactly what it does. And here's the thing that makes it defensible rather than a gimmick: every word in that line is optional. This is the same statement:

f = x -> x^2

Both parse to the identical AST. The natural-language layer is a set of aliases over conventional syntax, and you can mix them freely. I want to explain why I built it this way, how it works, where the parser design got hairy, and where I think the line is between "readable" and "footgun."

Why a new language at all?

The obvious question first. MATLAB, Julia, Python/NumPy exist. Why not run one of those in the browser?

I actually tried. The project started as an attempt to rewrite Julia in TypeScript, and it failed — not because Julia is bad, but because Julia's semantics are deeply coupled to its native compilation pipeline. You can't extract "Julia the language" from "Julia the LLVM-based JIT" without either shipping the whole thing (huge, slow to start, hostile to notebook-style cell execution) or reimplementing so much that you've built a new language anyway, one that's subtly incompatible in a hundred places. Subtle incompatibility is the worst outcome: users bring expectations you can't meet.

Porting MATLAB has the same problem plus a legal one, and CPython-in-WASM (Pyodide) is genuinely good — but it drags an entire CPython runtime and package ecosystem into every page load, and the numerical core still isn't designed around browser primitives like SharedArrayBuffer.

So the constraints that shaped Equana were:

  1. Pure TypeScript AST-walking interpreter. No JIT, no bytecode. Cells parse and run instantly, breakpoints and stepping are trivial (you control the tree walk), and each web worker runs its own interpreter instance over shared memory.
  2. Numerical work happens in WASM kernels, not in the interpreter. The interpreter holds handles (pointer + dtype + shape + strides) into one shared linear memory; BLAS/LAPACK-style binaries operate on it zero-copy. So the interpreter can afford to be slow and simple — it's orchestration, not computation.
  3. Familiar to Julia and MATLAB users without claiming compatibility with either. 1-based indexing, multiple dispatch, dynamic typing with optional annotations.

Once you accept you're designing a new surface syntax anyway, you get to ask a question that established languages can't: what should numerical code look like in 2026, for people who are scientists first and programmers second?

The conventional layer

Before the English part, the baseline. Equana's conventional syntax is unremarkable on purpose:

x = 42 arr = [3, 1, 4, 1, 5, 9] f = x -> x^2 function r = deg2rad(x) { r = x * pi / 180 } for x in arr { s = s + x } greeting = "hello ${name}" r = 1:2:10 # ranges, 1-based indexing everywhere

If you've written Julia or MATLAB, nothing here needs explaining. That was the goal: the conventional layer is the floor everyone can stand on. The natural-language layer is built strictly on top of it.

The natural-language layer

Every feature below is an alias or a sugar. None of them introduce new semantics.

let, be, equals, in. be and equals alias =. in aliases : in type annotations. let is the interesting one: it's a reserved keyword with no semantic effect whatsoever. It exists purely so the sentence scans.

let x be 42 let x in Float64 be 3.0 # same as x: Float64 = 3.0

I went back and forth on let being cosmetic. It feels wasteful to reserve a keyword that does nothing. But without it, x be 42 reads like a fragment; with it, the line is a sentence. The type-annotated form was the real test: "let x in Float64 be 3.0" is how you'd say it to a colleague at a whiteboard.

mapsto. Aliases ->. Mathematicians write f: x ↦ x². mapsto is just the ASCII spelling of ↦:

let square be x mapsto x^2

Bracket-free calls. A known function name followed by an expression is a call, chaining right-to-left:

println sqrt(16.0) sin cos 0.0 # = sin(cos(0.0))

Implicit multiplication. A numeric literal followed by an expression multiplies, the way it does on paper:

y = 2x + 3x^2 2 sin x # = 2 * sin(x)

Infix everything. In Equana, a + b is not special — it desugars to add(a, b) in the parser. Which means the reverse direction is cheap to offer: any binary function can be written infix. Built-ins work out of the box (3 add 4, 3 lt 5), and @infix(n) opts your own functions in with an explicit precedence:

@infix(10) function r = cross(a, b) { r = a * b } 3 cross 4 # same as cross(3, 4)

Method syntax. Any function can be called as a method on its first argument — 5.double() is double(5). It's not OOP; dispatch is still multiple dispatch on all arguments. It exists because chained postfix reads better than nested prefix for pipelines.

The unifying idea: one dispatch system, four spellings. add(a, b), a.add(b), a add b, and a + b are the same call. The language doesn't have an "English mode" — it has a larger set of equivalent notations, and you pick per-line whichever reads best.

The part where the parser fights back

Everything above sounds free. It is not free. The tension is fundamental: every token you make optional is ambiguity you hand the parser.

Consider a foo b. Is that an infix call foo(a, b)? Or a * foo(b) via implicit multiplication and a bracket-free call? Or a * foo * b? Without context, all three are plausible. A naive implementation ends up with a context-sensitive grammar, backtracking, and error messages nobody can act on.

Equana resolves this with two rules and a two-pass parse.

Rule 1 — function-call priority: a known function name followed by an expression is always a call. sin x is sin(x), never sin * x.

Rule 2 — numeric implicit mul: a numeric literal or non-function value followed by an expression is multiplication. 2 pi is mul(2, pi).

Both rules hinge on the parser knowing what's a function. That's the context-sensitivity problem. The fix is to split parsing per cell into two passes:

  1. Registry scan. A lightweight pass that only collects function names and @infix(n) declarations into a registry. It doesn't parse bodies — it just finds the declarations.
  2. Full parse. A completely standard Pratt parser with a precedence table, consulting the registry as a static lookup. When it sees a foo b: if foo is registered infix, it's an infix call at the declared precedence; if foo is a known function, Rule 1 applies; otherwise Rule 2.

The payoff is that the expression parser itself stays context-free — no backtracking, no feedback from the evaluator into the lexer (the trick that makes C and Perl grammars miserable). All the context lives in one flat name→precedence table built before expression parsing starts.

There are still sharp edges I chose to accept. Parentheses always force a call, so you can disambiguate explicitly. end and in had to be reserved outright. And precedence of user infix operators is the user's problem — @infix(n) makes you say the number, because inferring precedence is how you get operator soup.

Optional syntax: good idea or footgun?

I want to be honest about this, because "there are four ways to write everything" is exactly what style-guide authors have nightmares about. Perl's "there's more than one way to do it" is widely blamed for write-only code. So when is optional syntax defensible?

My working rules, after building this:

Aliases are fine when they're total synonyms with zero semantic delta. be is =. mapsto is ->. There is no behavior difference to remember, so the cost of reading unfamiliar-flavored code is one vocabulary lookup, not a semantics lookup. The moment an alias has even slightly different behavior, it stops being readability and becomes a trap. (This is why let being purely cosmetic was the right call, despite feeling wasteful: let that did something — hoisting, scoping, immutability — while looking optional would be the worst of both worlds.)

Sugar is fine when it desugars in one obvious step. 5.double()double(5) is mechanical. A reader who knows the rule can always recover the canonical form in their head.

It becomes a footgun when spellings interact. Implicit multiplication plus bracket-free calls is the danger zone in Equana — 2 sin x requires knowing both rules and their priority to read correctly. I kept it because the domain justifies it: scientific code is dense with 2πr-shaped expressions, and the audience already reads them on paper. In a general-purpose language I would not ship implicit multiplication.

Who's reading matters more than who's writing. Equana's audience is engineers, scientists, and students, many of whom are occasional programmers. For them, let f be x mapsto x^2 isn't cute — it removes a real barrier, the wall of sigils that makes code feel like someone else's profession. The experienced programmer's f = x -> x^2 is three keystrokes shorter and always available. Optionality means neither group pays for the other.

The honest caveat: Equana is in alpha, and this thesis is not yet validated at scale. Mixed-style codebases might turn out to be a real cost once teams share notebooks. If that happens, the answer is probably a formatter with a style flag — canonicalize to one spelling on save — rather than removing the aliases. The AST doesn't care either way, which is the point.

Where it stands

The language today ships with 20 packages and 282 functions — NDArrays, sparse matrices, linear algebra, FFT, statistics, plotting — with the numerical kernels running as WASM binaries over shared memory, and a notebook with a real debugger on top. You can try every snippet in this post right here at equana.dev (alpha, sign-in required), and the interpreter is on npm as equana.

I've written up the systems side in two companion posts: zero-copy WASM kernels over SharedArrayBuffer, and why the interpreter uses reference counting instead of a GC. If you have opinions on optional syntax, especially war stories from Perl, Scala, or Ruby DSLs, I genuinely want to hear them.

Next: FFT signal analysis in your browser in 10 lines