- #xpr-lang
- #language design
- #open source
- #data pipelines
- #sandboxing
Why I Built an Expression Language Nobody Asked For
The origin story of xpr-lang, a sandboxed expression language born from watching too many teams reinvent eval() for their data pipelines.
It always starts the same way.
You’re building a data pipeline. Maybe it’s an ETL service, maybe it’s event routing, maybe it’s a multi-tenant platform where customers configure their own data workflows. Somewhere in the config schema, you add a field called transform. Just a simple string. Maybe it uppercases a name or concatenates two address fields.
“We’ll keep it simple,” you tell yourself.
Three months later, someone asks for conditionals. Then string formatting. Then date math. Then a customer submits a support ticket because their transform expression is throwing a ReferenceError at 3 AM, and you realize that innocent string field has quietly mutated into an interpreter. Worse, it’s running eval() on input you don’t control.
I’ve watched this exact movie play out at three different companies over ten years of building data platforms. The specific configs were different, the downstream consequences varied, but the trajectory was always the same: simple transforms grow into complex expressions, and complex expressions demand a real language.
The question is whether you end up with a real language you chose, or one that grew out of accumulated hacks.
The third time I watched it happen, I decided to write a different ending.
The eval() Spiral
Let me illustrate the progression I kept seeing. It always starts innocently:
// Version 1: "Just a simple transform"
const result = config.transform
? record[config.transform.field].toUpperCase()
: record;
A few weeks later, someone needs conditional logic:
// Version 2: "OK, we need a little more flexibility"
const result = eval(`(function(record) { ${config.transform} })`)(record);
// config.transform = "return record.name.toUpperCase()"
Six months in, the eval is running untrusted user input:
// Version 3: "This is fine. Everything is fine."
// config.transform now comes from a customer-facing UI
const result = eval(customerProvidedTransform)(record);
// Pray nobody writes: require('child_process').exec('rm -rf /')
I’ve seen version 3 in production. Twice. Both times the team knew it was dangerous, both times they’d planned to “replace it with something proper” but never got around to it. The backlog always had something more urgent.
The Graveyard of Alternatives
Before building anything, I spent weeks evaluating what already existed. The landscape of “safe expression evaluation” is broader than you’d expect, and every option comes with a catch.
Full JavaScript eval
The path of least resistance, and the most dangerous. You get the entire language surface area, including require('child_process') and while(true){}. Sandboxing V8 properly is a full-time job.
I’ve seen teams try vm2, isolated-vm, various iframe tricks. Every approach eventually hit an escape vector or a resource exhaustion bug. The Node.js docs literally warn against using the vm module as a security mechanism.
One team I worked with discovered their “sandboxed” eval was vulnerable to prototype pollution. A customer’s transform expression could modify Object.prototype and affect every subsequent evaluation in the process. They found out because a different customer’s transforms started returning wrong results on Tuesdays. Debugging that one took three engineers two weeks.
Template Languages
Jinja, Handlebars, Mustache. Fine for string interpolation, terrible for computation. The moment someone needs if price > 100 then discount * 0.9 else price, you’re bolting logic onto a system designed for {{name}}.
I once watched a team build a custom Jinja extension library with 40+ filters, each one a tiny workaround for the fact that Jinja isn’t an expression language. They called it “JinjaPlus.” Nobody was proud of it.
CEL (Common Expression Language)
Google’s offering. Well-designed, genuinely safe, built for exactly this use case. But it’s Go-native with a C++ reference implementation. No first-class TypeScript runtime.
If your pipeline has services in three languages (and mine always do), you’re maintaining FFI bindings or accepting behavioral drift between environments. CEL also carries Google’s particular opinions about type coercion that don’t always match what product teams expect.
jq and JSONata
Powerful for JSON transformation. Also completely alien syntax to anyone who hasn’t spent a week studying them.
I once pair-programmed with a senior engineer who stared at a JSONata expression for ten minutes before saying, “I think this is APL.” Getting a product team to write and debug their own transform rules in jq is a losing proposition. The learning curve isn’t steep so much as perpendicular.
Writing It Twice
The option nobody talks about but everybody does. You write the expression parser in Python for the batch pipeline, then again in Go for the streaming service, then discover they disagree on how null == 0 works. Now you have two interpreters pretending to be one language, and your conformance tests are a shared Google Doc someone last updated in Q2.
I’ve personally lived through this at two jobs. Both times, the Python and Go versions started identical and drifted apart within six months. Edge cases in string sorting, floating-point formatting, truthiness rules. Each runtime’s implementation reflected the idioms of its host language rather than the semantics of the expression language.
Users noticed. The same expression produced different outputs depending on which service evaluated it.
None of these approaches gave me what I actually needed: something safe, something familiar, something that behaved identically regardless of which runtime evaluated it.
Design Constraints as Features
When I started sketching xpr-lang in late 2025, I made a list of things the language would not do. That list turned out to be more important than the feature set.
No I/O. No file reads, no network calls, no environment variable access. An expression can’t reach outside its evaluation context. Period. This isn’t a limitation. It’s the whole point.
When you can prove that an expression has no side effects, you can safely run untrusted input from customers, from config files, from LLM-generated logic. You don’t need a sandbox runtime because the language is the sandbox.
No loops. No for, no while, no unbounded recursion. If you can’t loop, you can’t hang. Every expression terminates. Every evaluation is bounded.
This matters enormously when your expression engine runs in a hot path processing thousands of events per second. You never have to worry about a customer’s misconfigured expression consuming your entire CPU budget.
No imports, no eval. The surface area of the language is exactly what ships in the standard library. No dynamic code loading, no metaprogramming, no require(), no __import__(). The set of operations available is auditable and fixed at compile time.
These constraints sound severe if you think of xpr-lang as a general-purpose language. It isn’t one. It’s a transform and decision language, purpose-built for the exact use cases that keep showing up in data pipelines: filter this list, map these fields, score this record, route this event.
With I/O, loops, and dynamic evaluation off the table, whole categories of security vulnerabilities become structurally impossible:
- No injection attacks, because there’s nothing to inject into
- No denial-of-service, because expressions can’t loop
- No data exfiltration, because the language has no concept of a network socket
- No supply-chain attacks, because there are no imports
Side by side, the difference isn’t a longer list of blocked operations. It’s where the boundary lives: outside the evaluator in one case, inside the grammar in the other.
The Familiar Syntax Bet
A language nobody can read is a language nobody will adopt. I’ve seen too many internal DSLs die slow deaths because they required a three-day training session to write a filter expression.
xpr-lang borrows heavily from modern JavaScript and Python syntax. If you’ve written an arrow function, a ternary expression, or a method chain, you can read xpr-lang without a tutorial:
// Filter and transform a list of users
users
|> filter(u => u.active && u.age >= 18)
|> map(u => {
...u,
displayName: upper(u.firstName) ++ " " ++ u.lastName,
tier: u.purchases > 100 ? "gold" : "standard"
})
|> sortBy("displayName")
The pipe operator (|>) is the star of the show. Data pipelines are conceptually about chaining transforms, and pipe syntax makes that chain visible. Instead of nesting sortBy(map(filter(users, ...), ...), ...) into an unreadable mess, each step reads top to bottom, left to right.
Here’s a simpler example showing conditional logic and string manipulation:
// Route an event based on severity and source
let severity = event.level >= 90
? "critical"
: event.level >= 50
? "warning"
: "info"
let channel = lower(event.source) ++ "." ++ severity
{ severity, channel, timestamp: now(), original: event }
And here’s a data scoring expression you might see in a lead-qualification pipeline:
// Score a lead based on weighted factors
let engagement = record.pageViews * 0.3 + record.downloads * 0.5 + record.signups * 0.2
let recency = dateDiff(now(), record.lastActivity, "days")
{
score: round(engagement * (recency < 30 ? 1.0 : 0.6), 2),
tier: engagement > 80 ? "hot" : engagement > 40 ? "warm" : "cold",
qualified: engagement > 60 && recency < 90
}
Other familiar features in v0.5 include let bindings for intermediate values, spread syntax for merging objects, optional chaining (user?.address?.city), regex literals, destructuring, and 71 built-in methods covering strings, arrays, objects, math, and date operations.
The goal isn’t to be JavaScript. It’s to be readable by someone who knows JavaScript or Python, with none of the footguns.
Spec First, Not Implementation First
Most languages grow organically. Someone writes a parser, adds features, and the “spec” is whatever the implementation happens to do. When you only have one runtime, this works fine. When you have three, it’s a disaster.
xpr-lang starts from a formal EBNF grammar. The grammar lives in the main repository and serves as the single source of truth for all three implementations. Every production rule, every operator precedence level, every associativity choice is documented in the spec before any runtime implements it.
This might sound like bureaucratic overhead. In practice, it’s saved me hundreds of debugging hours. When the Python implementation disagrees with the Go implementation, the spec is the tiebreaker. There’s no “well, the TypeScript version does X so that must be correct.” The spec says what’s correct. Implementations conform or they’re wrong.
The companion to the grammar is the YAML conformance test suite: 601 test cases, each specifying an expression, optional input data, and the exact expected output bytes.
# Sample conformance test cases
- description: "Pipe operator with filter and map"
expression: |
[1, 2, 3, 4, 5]
|> filter(x => x > 2)
|> map(x => x * 10)
expected: [30, 40, 50]
- description: "Optional chaining returns null on missing"
expression: "user?.address?.city"
input:
user: null
expected: null
- description: "Spread merge with override"
expression: '{ ...base, status: "active" }'
input:
base: { name: "test", status: "pending" }
expected: { name: "test", status: "active" }
All three runtimes run the same YAML file in CI. A case fails in any runtime, the PR doesn’t merge. No exceptions, no “we’ll fix it later” tickets.
When I want to add a new feature, the workflow goes: write the YAML test cases first, update the EBNF grammar, then implement in each runtime until all tests pass. The tests encode the behavior I want. The grammar encodes the syntax. The implementations are just… implementations.
I wrote about this process in much more detail in One Grammar, Three Runtimes.
Getting Started
If any of this resonates, here’s what using xpr-lang looks like. Install from your language’s package registry:
# TypeScript / JavaScript
npm install @xpr-lang/xpr
# Python
pip install xpr-lang
# Go
go get github.com/xpr-lang/xpr-go
Then evaluate expressions safely:
import { parse, evaluate } from "@xpr-lang/xpr";
// Parse the expression (validates syntax)
const ast = parse('users |> filter(u => u.active) |> length()');
// Evaluate against data (no I/O, no loops, no side effects)
const result = evaluate(ast, {
users: [
{ name: "Alice", active: true },
{ name: "Bob", active: false },
{ name: "Charlie", active: true },
]
});
console.log(result); // 2
The same expression, given the same data, produces the same output in all three runtimes. That’s the guarantee.
Where It Stands
xpr-lang shipped to three package registries:
- npm:
@xpr-lang/xpr - PyPI:
xpr-lang - Go modules:
github.com/xpr-lang/xpr-go
There’s a documentation site built with VitePress and an interactive browser playground (Vite + CodeMirror) where you can try expressions without installing anything. Type an expression, see the parsed AST, inspect the output. It’s the fastest way to get a feel for the language.
The project is open source, actively maintained, and I use it in production for exactly the problem that prompted its creation: safe evaluation of user-defined transform expressions in a multi-tenant data platform. The same expressions run in a browser-based preview and in Go backend services, producing identical results.
What Building a Language Taught Me
After more than a decade of writing software, building a language from scratch taught me more about programming than any single project before it.
Writing a parser forces you to understand how every expression you’ve ever typed actually gets interpreted. The operator precedence you take for granted? Someone decided that * binds tighter than +, encoded it in a grammar production rule, and implemented it in a recursive-descent function. Now I’ve done that myself, three times over, and I read error messages differently.
When a compiler tells me “unexpected token,” I can picture exactly where the parser got confused.
Maintaining three implementations of the same language taught me that “the same logic” in different host languages is never truly the same. Go’s integer division truncates toward zero. Python’s floors toward negative infinity. Both call it “integer division.” The conformance suite is full of test cases I’d never have thought to write if I’d only been working in one language.
The constraint-driven design process changed how I think about features in any project. Every feature request for xpr-lang gets filtered through one question: does this preserve the safety guarantees? If adding a feature means expressions could loop, or access the network, or produce side effects, the answer is no. Regardless of how useful the feature would be.
Saying no to features turned out to be the hardest and most valuable discipline in language design.
Nobody asked me to build xpr-lang. But after watching the same problem surface across multiple teams, multiple companies, and multiple tech stacks, I’m glad I finally did.
You can explore the project at /projects/xpr-lang, try the playground, or browse the source on GitHub.
This post covers the “why” of xpr-lang. If you’re curious about the “how,” particularly the surprisingly painful process of making three language runtimes produce byte-identical output, read One Grammar, Three Runtimes.