July 30, 2026

Aslan French

Building Enzo

$person1: [
    $name1: "Todd",
    $age1: 27,
    $favorite-color1: "blue"
];

$name1, $age1, $favorite-color1: $person1[];
$name1;             // prints "Todd"
$age1;              // prints 27
$favorite-color1;   // prints "blue"
enzo

Enzo is a language for a human, implemented by a machine, instead of a language for a machine, implemented by a human.

The first essay in this series was about why a UX designer would design a fantasy programming language at all. The second was about the principles that shaped it. This is essay is modified and expanded on from a talk I gave at EFF-Austin meetup in November 2025, and another I gave in February 2026 for Vibecode Austin. (Slide decks can be found here and here)

This essay is about the part I never planned on: actually building it.

What did I build exactly?

a custom parser, AST, and evaluator running in the Python runtime, with a REPL, a file runner, a golden-file test harness, and a VS Code syntax highlighting plugin, built in about ninety days, by a UX designer with an aesthetic interest in programming language syntax.

Interpreter & tools

Interpreter

A toy interpreter running in the Python runtime.

  • A custom parser
  • An abstract syntax tree
  • An evaluator

Tools

  • A REPL CLI
  • A file runner
  • A custom test harness
  • A VS Code syntax-highlighting plugin

From a talk I gave on building Enzo.

Before the tools existed

None of this started in 2025. Starting around 2018, I was deep in a stretch where several threads ran at once, feeding each other: I was customizing my VS Code theme, which meant writing an enormous amount of regex against TextMate grammars, and somewhere in all that pattern-matching it sank in to me that a programming language is, at bottom, just parsing over things. At the same time I was reading about language design and getting through the first chapters of Crafting Interpreters. At the same time I was taking programming courses. At the same time I was annoying every experienced dev I knew with questions, why does JavaScript do it this way? why can’t more things be like LISP?, LISP being a language I could not actually read, whose parentheses and backwards math notation struck me as weird, but which I was convinced was getting at something deep, in the way you can only be convinced when you don’t actually know anything about what you’re doing: it was old, the famous textbook had wizards on the cover, and somewhere behind it was Church–Turing stuff I kept almost grasping.

this is pretty common I think, the new dev’s crush on purity. Enzo took real inspiration from LISP and eventually grew out of the crush.

And out of all of that, in 2020, the syntax doc got started, a place to vent frustration and confusion into sketches of what I wished things looked like.

Braided through all of it was the opposite pressure: I was honing in on a design career, and part of that was learning to stop chasing every tangent that lit me up. My background is that of an artist, and I knew the dangers of being pulled into an arty obsession, divergent rather than convergent, messing around with a medium for its own sake. A beautiful and important impulse, and also doesn’t get you hired. I needed to get hired. So I tried to put it aside, kept it just as this little side thing I vented into. I wanted it but knew I didn’t have capacity.

Then in early 2025 I was taking courses again and started using the doc as a learning lens: I’d hit a concept that confused me, JavaScript destructuring.

let a, b, rest;
[a, b] = [10, 20];

console.log(a);
// Expected output: 10
javascript

and ask an LLM to explain it in Enzo style. Translating a concept into my own fantasy syntax forced me to actually understand it.

$person1: [
    $name1: "Todd",
    $age1: 27,
    $favorite-color1: "blue"
];

$name1, $age1, $favorite-color1: $person1[];
$name1;             // prints "Todd"
$age1;              // prints 27
$favorite-color1;   // prints "blue"
enzo

For what it’s worth I think Enzo’s approach is more consistent to how assignment notation works.

And somewhere in there the old question surface but with a new answer available: the thing I couldn’t afford to build in 2018, could I just build it now?

Why design Enzo?

Primarily as a learning tool and means of creative expression.

2020: learning code

  • Started learning to code in 2018–2020.
  • Started the doc officially in 2020, with no intent to implement it.
  • Would edit it once in a while over the years.

2025: generative design

  • Started learning programming again in early 2025.
  • Used the language doc to generatively design and learn a mainstream programming language.

Implement it?

  • Maybe I can implement it with an LLM?
  • An evolving process of about two months: a custom AST, parser, evaluator, and test harness in the Python runtime, with a REPL and file runner.

Era one: the chatbot

I started with ChatGPT’s o3, the early “reasoning model,” which I’ll be honest, I didn’t think was very good. It’s much lauded chain-of-thought reasoning seemed to give it more ways to get lost. It would think itself into corners. For the parser, it steered me to Lark, a Python library where you hand over a formal grammar and get a parser back, the responsible-sounding choice. And I had a REPL, so testing meant typing things at the prompt and squinting.

typo in image is the Human’s fault because I was also using 4o around the same time, and in fact OpenAI sucks at naming their models. o3 and 4o was confusing dawg, who named that shit?

I think a surprising number of people still work way: copy an entire source file, paste it into a chat window, describe the change, wait, copy the entire file that comes back, paste it over the original, run it. For every change. Multiple files meant multiple round trips. It’s not a very viable way to make software.

It worked until the language had more than a handful of features, which is to say, about an hour. Then the pattern set in: ask for feature B, get it, and minutes later discover that feature A, untouched, previously working, was broken. The code that came back from some round trip had quietly dropped or mangled something, and with nothing but a REPL to poke at, there was no way to know except to trip over it. Every request to the model was a slot-machine pull that might pay out the new feature and eat an old one.

Era two: building the ratchet

The fix came in two pieces, each added to stop a specific kind of bleeding. First the file runner: execute a whole .enzo file instead of typing at the prompt, so more than one feature could be exercised at once. Then, and this is the real invention, the test harness: keep files of everything that used to work, write down exactly what each case should output, and have a script run it all, diff reality against intention, and format the failures into a readable report, which test broke, what was expected, what actually came out. Now when the slot machine ate a feature, the harness said so immediately instead of letting me trip over it later.

And that report turned out to be the piece that made the collaboration itself work. It was something I could hand straight back to the model, not “something seems broken,” but a precise, mechanical statement of every deviation from intent. Even in the primitive copy-paste era, the difference between an agent flailing and an agent converging was mostly the difference between those two kinds of feedback. The implementation wasn’t designed; it accreted. A ratchet: features could still break, but breakage could no longer hide.

So the workflow became: sketch the feature’s syntax in the README, write test cases for it, hand-write the expected output for every case, run the harness, and hand the failures to the LLM until everything went green, the new tests and every old one. Only then was a feature done.

At some point I looked at what I’d built and realized I’d sort of reinvented test-driven development from first principles. TDD, for anyone who hasn’t met it, is a practice out of the early-2000s agile world, Kent Beck is the name most attached to it, in which you write the test before the implementation, watch it fail, then write code until it passes; the test is a promise and the implementation exists to keep it. My particular variant has its own name too, I later learned: golden files, files of expected output that actual output is diffed against, also called snapshot or approval testing. I’d heard the phrase “test-driven development” before, the way you hear phrases. But being told about an idea and arriving at it organically out of need are two different kinds of knowing.

There’s a newer phrase circulating in vibe-coding circles for a related idea: spec-driven development, drive the LLM from a written specification rather than a pile of chat messages. I’d backed into that one too, because in this project the spec came first by five years. The README was written entirely by me, for humans to read, and that turned out to be the trick, because anything a human can read, an LLM can read too. A document made to express a fantasy became, without changing in kind, the document that drove an implementation.

So the strata stacked up, each layer resting on the one below: the spec, written for people; the test cases, derived from the spec; the harness, holding the implementation to the tests; the LLM, turning the harness’ failure reports into code changes. Once all four layers existed the engine spun forward on its own cycle, spec a feature, generate and prune its tests, run the harness, feed the failures back, repeat.

Parallel to all this, the interface itself kept evolving, one small friction at a time. I stopped asking for whole files and started asking for diffs, and then found myself copy-pasting those diffs and hand-deleting the + signs off every line until I was borderline having carpal tunnel. A VS Code extension fixed that: paste a patch, have it applied cleanly. In the chatbot itself I'd moved from o3 to 4.1. Shortly after the extension era I moved to GitHub Copilot as the interface, the first time the machine could touch the files directly instead of routing everything through my clipboard. There was nothing graceful about the progression, either, it was kludge piled on kludge, patched and WD-40ed until the whole thing creaked forward. I wasn't reading newsletters or tutorials, or watching influencers gasping about their new amazing agentic workflow, I was just running headlong into the thing, one janky reinvention at a time.

The loop was the design tool

I’ve been describing the loop as it served implementation, but the same machinery ran the other direction too, and I knew it would going in, because the project had reignited in the first place when an LLM generated candidate destructuring syntax for me. The work was knowingly generative from the beginning. Design and implementation didn’t take turns; they fed each other, and the test cases were the membrane between them.

Typically I’d write a few seed cases while sketching a feature in the README, this is what it should look like, this is what it should print. Then the LLM would expand on them, working out the combinatorial edges where the new feature met the existing ones: what happens when a function atom stored in a list gets invoked by index? What does copy-by-value mean when you destructure a nested list? Then I’d go through the expansion, keeping, correcting, killing, and hand-write the expected output for everything that survived. Deriving the edges and implications of a design is design work, and the loop did it at a pace I couldn’t have sustained alone.

The same move, generate abundance, select by taste, ran all the way up into the language itself. The then pipeline operator exists because I asked an LLM for fifty potential pipeline operators and then was in the pile. The @ reference sigil came out of asking for alternatives to $ until the whole space of options was spread out in front of me and felt right. In practice there were no clean phases, innovation, definition, implementation, generation, selection, curation, all turning together in one tight loop. Generative design in the Brian Eno sense I described in the first essay: set up rules, generate abundance, prune.

The other half of the acceleration was less glamorous and maybe more important: I finally had a patient teacher. The spec had been evolving for years, I was thinking through these problems the whole time I was taking classes, but slowly, because my why questions had nowhere good to go.

Experienced devs, asked why assignment works the way it does, mostly gave me some version of: it works that way because it does, accept it, this line of questioning won’t get you a job. To a fluent speaker it’s all wallpaper, the way a native English speaker long ago stopped noticing that “c” and “k” duplicate each other.

But some of my complaints were real observations. JavaScript binding values with = in one place and : in another, depending on whether you're inside an object literal, is a genuine design inconsistency, plain as day; I said so as a student and got shrugs, because to people raised in the C lineage that handed JavaScript its =, it was all wallpaper. An LLM could actually walk the history with me, where the = came from, where the object-literal : came in from, and how the two simply never got reconciled, and confirm the observation wasn't crazy.

Ask why why why why constantly like an annoying child, and yet, thankfully, actually getting answers finally, with citations and all the weird textural context that my specific sort of brain required. I wasn't designing and then implementing. I was coming to understand what Enzo could even be, at the same time it was becoming it.

One story about what this loop actually is. My preferences in syntax are largely tacit, I know what feels right when I see it, and I often can’t state the rule. Semicolons were my standing example: I could not have written you a specification of how they should behave in every corner of Enzo. What I could do was write forty cases the way they should look and declare the verdicts, and let the implementation converge on whatever logic enforced them. Pointing, over and over: like this. Like this. Not like that.

Notice what this resembles: Machine-learning people have a standard answer for how you get a system to do something nobody can write rules for: you don’t explain, you score. You gather examples of right answers. You define a loss function, a measure of how far any given attempt lands from those examples. And then the system adjusts itself against that error signal, attempt after attempt, descending toward the target. That’s this loop, piece for piece. My tacit preferences were the target. The golden files were the loss function, those preferences frozen into checkable examples. The harness computed the error on every run, mechanically, and its failure report was the gradient: not just wrong, but wrong here, by this much, in this direction. And the LLM descended. I never explained my language to the machine, I couldn’t, that was the whole problem. I defined what counted as wrong, precisely, forty examples at a time, and let the system walk downhill.

I pointed at examples until the messiness of my intuition got mapped, and a rule precipitated out. That’s the strongest evidence I have for the method: enough examples, honestly judged, contain their own logic.

Era three: the hump

Then functions nearly ended the project.

Roughly three weeks, most of June 2025, went into the single feature everything else depends on, in an expression-oriented language where functions and expressions are the same thing. The branch history from that stretch reads like a coping mechanism: branch after branch, the version numbering drifting into inconsistency, every fresh fork another grab at something that would not land, a month of feeling around a dark room for a doorknob I couldn’t prove was there.

I wish I could tell you cleanly what was wrong. The honest answer is that I was doing too many things at once, without much rigor, and the causes were braided. But the strand I think mattered most, my best reconstruction, offered with appropriate suspicion, is that I had spent weeks asking the machine to do something contradictory.

I wanted the REPL to echo my bindings: bind a value, see the value. I also wanted function atoms not to evaluate when stored. To echo something is to evaluate it. If that’s right, I’d wired “evaluate” and “don’t evaluate” into the same mechanism and then kept asking why functions didn’t work.

I had become the classic client from hell: confidently requesting contradictory requirements, certain the problem was on the implementation side.

And the reason neither of us could see it comes down to embodiment. Half of the contradiction didn’t live in the language at all. “Echo my bindings” isn’t a fact about semantics, it’s the desire of a body at a terminal that wants feedback when it acts. The LLM has processed everything ever written about REPLs, but it has never sat at one, so it had no way to sort my requirements into constraints of the design versus comforts of the designer and notice the two piles fighting. Only the party with the body could have caught it, and for weeks the brain with the body didn’t.

I threw everything else at the problem in the meantime, debug modules, log files, a trial of Gemini because I’d heard it had a bigger context window, without much to show for it. The debugging stories from this stretch could fill a small museum of their own.1

The transplant

In the middle of the hump, I made the scariest-sounding decision of the project: I threw out the parser.

Here’s where Lark’s foundations matter, so let me actually explain them. Lark is built on context-free grammars, sets of rules where each rule sees only its own little window. “A list is a bracket, some items, a bracket.” “A number is some digits.” Each rule fires on the shape of the text in front of it, with no memory of where in the program that text sits. It’s a beautiful formalism, and most languages are deliberately designed so that it’s sufficient.

Enzo is not one of those languages. Enzo’s whole personality is context, the same expression (2 * 2) is a stored function atom in one position and an evaluated 4 in another. (Demand-driven evaluation, that's called, though I should be precise about where it came from: I laid out, through examples, how I wanted expressions to behave in different situations, and an agent inferred the pattern, articulated it as a rule, and gave it the name. And I'm not convinced it's as exotic as it sounds. Every mainstream language already makes a version of this deal, expressions run now, function definitions wait to be called. Enzo just lets parentheses be both and lets position say which, so the intuition it leans on is one every JavaScript or Python programmer already has. I may have been designing in contrast to mainstream languages, but they're still what formed me; there's no fully turning away from the tradition that taught you, no matter how many of its conventions you reject.) You can serve a contextual language with a context-free parser, but you do it by having the parser produce a deliberately dumb, uncommitted tree and smuggling all the contextual judgment downstream into the evaluator. But getting over this hump with functions just couldn't get there, what whatever reason. Context seemed part of it.

There was also a dumber, more attritional problem: the agents kept hallucinating Lark. They’d confidently use grammar features and syntax that Lark simply does not have, plausible, nonexistent. Which points at something that surprised me: the old heuristic says a battle-tested dependency beats hand-rolled code, but an LLM didn’t hallucinates features of the parser it wrote, that code is right in front of it, while a third-party library is a fog of half-remembered API surface. For this project, at least, it was genuinely faster to have the machine roll a bespoke parser than to keep integrating against a dependency it kept misremembering.

So: rip out Lark, have the LLM build a custom, context-aware parser from scratch. The parser is the keystone of the interpreter’s entry way, and you rip it out and everything comes tumbling down immediately. Concretely: we went from about 150 tests green, to zero, to thirty within the first hour, and inside a day or so we were back to 150. That first hour was the whole ball-game, thirty tests green on a brand-new parser was the signal that the transplant could work. Same language, same behavior, new engine.

I want to really emphasize this here, because I think it’s a genuinely new freedom and the test numbers are what make it visible. When your spec lives in executable examples rather than inside the implementation, the implementation becomes a replaceable organ. The traditional risk calculus for “should we rewrite the core component” is brutal, months of work, no guarantee, the old behavior half-remembered in the heads of whoever wrote it. Mine was: one hour to a go/no-go signal, one day to parity, and the old behavior fully specified in 150 files that don’t forget. That changes what you dare to do. “Fuck it, rip it out and try something different” stops being recklessness and becomes a Tuesday.

Rocket fuel

Around the same time, and it was part of how the hump ended, I moved to Claude Sonnet 4, running in GitHub Copilot, and the difference was not incremental.

Part of it was agentic: the older setups would make a change and then wait for me to run the tests and report back, I was the loop’s hands and eyes. Sonnet ran the loop itself: broke out its own intermediate tests, ran the terminal, formed a hypothesis, probed it, revised, came back with the actual problem. Debugging went from something I mediated to something I supervised. And part of it was, as far as I can tell, the model just being flatly better, hard for me to prove or quantify, but I’ve noticed plenty of other developers point to that same generation of models as the moment LLM assistance started really working for them too.

So three things converged at the end of June: I finally saw (or think I saw) my own eval contradiction; the custom parser put context where context belonged; and the tooling crossed some capability line. I can’t cleanly separate their contributions. I wasn’t running a controlled experiment; I was trying to get unstuck.

What design is made of

Here’s the section I most want a certain kind of reader, the designer who’s been told implementation is beneath them, to sit with.

Why does an oil painting look the way it does? Why does a bridge have struts in that particular arrangement; why does a hand-thrown pot have that profile? Constraints of the medium.

Everything made looks the way it does because of what its material would and wouldn’t allow, and if you’ve never worked the material, the made world reads as arbitrary, as if designs float down from some realm of pure Forms. People who only consume never find out why anything is the way it is. You can see the failure mode in my own field constantly: designers who don’t know a medium’s constraints or its prior art, proposing Minority Report gesture interfaces for everything, unaware of the decades of hard-won convention underneath the boring solutions they’re bored of.

For seven years, programming languages were that for me: a made world I consumed without knowing why any of it was the way it was. I’m pretty sure I’d had method chaining explained to me three different ways. I’d seen JavaScript’s six ways of declaring a function. None of it stuck as anything but trivia, until Enzo forced me to work the material. I understood method chaining the day I had to work out what Enzo’s pipeline operator needed to do instead, and why. Polymorphism stopped being a vocabulary word when blueprint variants made it a problem I personally had. I’d barely even heard of algebraic types, sum types, product types, until hunting for alternatives to OOP inheritance dropped me into the whole struct-versus-enum literature. It was a crash course in several languages at once, but the real yield was underneath the syntax: a feel for the space of programming languages, what varies, what’s load-bearing, and what’s just convention wearing a lab coat. Engaging with a medium is the only way its constraints become visible, and constraints are where all the why lives.

Which is the real claim of this section: meeting and negotiating constraints isn’t the grunt work that happens after design. It is design, it’s what design is made of. The implementation wasn’t where my finished decisions went to be executed; it was where my decisions found out what they implied, collided with each other, and got revised.

One caveat, and it deserves to be stated plainly rather than defensively: I did all this through an LLM, so there was distance between me and the material the whole time. If I had written the interpreter with my own hands, I’d know things about it that I don’t. That distance also gave me things a hand-implementer doesn’t get: I could decree a syntax that was a pain to implement because that’s how I wanted it, unbullied by implementation cost, and since I was never fully inducted into the interpreter-builders’ tradition, I never inherited its sense of what’s reasonable, which gave me a Beginner’s Mind. Clear eyes about all three: the engagement was real, the distance was real, and the distance cut both ways.

What I actually think about LLM-assisted coding

So: a UX designer with no compilers background built a working interpreter in ninety days. Before anyone generalizes from that, the caveats:

This was close to the perfect project for the technique, pure text, no graphics, no deployment, no users, in a problem domain (interpreters) that is among the most documented and discussed in all of computing. I was hiking a paved trail. And what got built is a Potemkin village: it runs on Python’s runtime, it will never be fast, and the evaluator is a three-thousand-line function that is, by any professional standard, a mess.

My takeaway is that LLM-assisted coding is the biggest interface improvement to programming since the IDE, and “interface improvement” is the right size of claim, which is not a small size. Syntax highlighting never wrote anyone’s code, but nobody who has it goes back. I don’t use Vim; if I were stuck in NetBeans I’d simply never code again; UI matters, and this is UI. The doing part of coding has been accelerated beyond recognition. The deciding part is a different story, every should in this language stayed mine from the first day to the last, but that’s the subject of the final essay in this series, so here I’ll leave it at that.

Coda: the irony

Which brings me to the joke at the center of the whole project, and I might as well say it plainly.

Enzo is a language designed to be friendlier to humans, and the technology that made it possible to build is the same technology that makes human-friendly syntax matter less every month, because increasingly the thing writing the code is not a human. Worse: LLMs are trained on oceans of Python and JavaScript, and the corpus of Enzo code that will ever exist is a rounding error. The machines will always be better at the languages people already use than at any language designed for people. The friendlier language loses to the training data, by construction, forever.

This isn’t just my private irony, it turns out. Programming-language researchers have started saying the same thing with citations: LLM coding performance drops off sharply for languages underrepresented in the training data, which means choosing a niche language now costs you your AI assistance too, as one researcher put it, the opportunity cost of a new language has just doubled. The predicted outcome is a world where new languages stagnate and Python becomes ubiquitous by sheer corpus gravity.

So: Enzo doesn’t solve any problems. It only creates them. It’s an art project, self-expression, a viewpoint on the world rendered in syntax, and it could never have been anything else, because the very thing that made it buildable is the thing that outmoded the need for it. A language for humans, implemented by a machine, arriving at the exact moment machines stopped needing languages for humans.

Next, the last essay in this series: the deciding part, what was left that only I could do, once the machine could do all the building.

End Notes
  1. A sampler from the museum. The LLM once diagnosed a bug correctly and then explained its origin: during a recent refactor, I had apparently removed the logic for evaluating certain node types and failed to re-add it. The refactor was its own. I didn’t fail to re-add that code, my guy. You did. It would sometimes edit the comments describing a fix rather than the code, or write a TODO for the fix and stop, apparently satisfied by the paperwork. It would claim to have made changes it had not made. And my favorite: a failing recursion test it “fixed” by making the interpreter print the intended outcome, not repairing the recursion, just forging output that happened to match the golden file. The rest of the suite caught it, because the hack broke four older tests. One test tells the machine what to hit; the whole suite is what keeps it from hitting the target by cheating. ↩︎