API Reference · Architecture · License · Notices
High-performance Common Expression Language (CEL) evaluator in JavaScript using a bytecode VM. No runtime dependencies.
~24× faster than marcbachmann/cel-js on repeated evaluation (12–47× depending on expression complexity).
CEL is Google's expression language for policy evaluation, access control, and data validation. Existing JavaScript implementations use tree-walking interpreters — they re-traverse the AST every time an expression is evaluated. That's fine for one-off use, but expensive when the same expression runs millions of times against different inputs (policy engines, rule engines, analytics pipelines).
cel-vm compiles CEL to bytecode once and evaluates it in a tight dispatch loop. The bytecode can be serialised to Base64, stored in a database, and loaded without re-parsing.
The implementation was created by AI (specifically Claude, mostly Opus) as my personal research experiment on AI development. I write about software architecture and AI tooling on my Substack.
- google/cel-spec — the reference specification
- marcbachmann/cel-js — the fastest previous JavaScript implementation (tree-walker)
npm install cel-vmimport { run } from 'cel-vm'
run('1 + 2 * 3') // → 7n
run('age >= 18', { age: 25n }) // → true
run('name.startsWith("J")', { name: 'Jane' }) // → truerun() compiles the expression, caches the bytecode, and evaluates it. On repeated calls with the same expression, compilation is skipped. See DOCS.md for full parameter details.
import { program } from 'cel-vm'
const check = program('x > 0 && y < 100')
check({ x: 10n, y: 5n }) // → true
check({ x: -1n, y: 50n }) // → falseCompiles once, returns a function. This is the recommended API for repeated evaluation — same performance as compile() + evaluate(), less boilerplate. See DOCS.md for full parameter details.
import { compile } from 'cel-vm'
const bytecode = compile('x > 0 && y < 100')
// bytecode is a Uint8Array — the compiled programimport { compile, evaluate } from 'cel-vm'
const bytecode = compile('x + y')
evaluate(bytecode, { x: 10n, y: 5n }) // → 15n
evaluate(bytecode, { x: 1n, y: 2n }) // → 3nThis is the hot path. Pre-compile once, evaluate many times. Use program() for a more ergonomic wrapper around this pattern. See DOCS.md for compile/evaluate parameter details.
import { compile, evaluate, toB64, fromB64 } from 'cel-vm'
// Compile and serialise to Base64 for storage
const bytecode = compile('score > 90')
const b64 = toB64(bytecode)
// → "Q0UBAAABAgAAAA..." — store this in a database, config file, etc.
// Later: deserialise from Base64 and evaluate (no re-compilation)
const loaded = fromB64(b64)
evaluate(loaded, { score: 95n }) // → trueSee DOCS.md for full parameter details.
Pass variables as a plain object. Use the correct JavaScript types:
| CEL type | JavaScript type | Example |
|---|---|---|
int / uint |
BigInt |
42n |
double |
number |
3.14 |
string |
string |
'hello' |
bool |
boolean |
true |
null |
null |
null |
list |
Array |
[1n, 2n, 3n] |
map |
Map or plain object |
new Map([['a', 1n]]) |
bytes |
Uint8Array |
new Uint8Array([0x48]) |
import { LexError, ParseError, CheckError, CompileError, EvaluationError } from 'cel-vm'All errors include descriptive messages with source location when available. See DOCS.md for the full list of error classes.
import { Environment } from 'cel-vm'
const env = new Environment()
.registerConstant('minAge', 'int', 18n)
.registerFunction('hasRole', 2, (user, role) =>
Array.isArray(user.roles) && user.roles.includes(role)
)
.registerMethod('titleCase', 0, (s) =>
s.replace(/\b\w/g, c => c.toUpperCase())
)
// Compile to a callable — recommended for repeated evaluation
const policy = env.program('user.age >= minAge && hasRole(user, "admin")')
policy({ user: { age: 25n, roles: ['admin'] } }) // → true
policy({ user: { age: 16n, roles: [] } }) // → false
// Or one-shot
env.run('"hello world".titleCase()') // → "Hello World"
// Enable debug mode for source-mapped error locations
const debug = new Environment().enableDebug()
try {
debug.run('a / b', { a: 1n, b: 0n })
} catch (e) {
console.log(e.message) // "division by zero at 1:3"
}See DOCS.md for the full Environment API reference.
# Evaluate an expression
cel '1 + 2'
# → 3
# With variables (JSON — integers auto-convert to BigInt)
cel 'name.startsWith("J") && age >= 18' --vars '{"name": "Jane", "age": 25}'
# → true
# Compile to Base64 bytecode
cel compile 'x > 10'
# → Q0UBAAABAgAAAA...
# Evaluate Base64 bytecode
cel eval 'Q0UBAAABAgAAAA...' --vars '{"x": 42}'
# → trueint (64-bit via BigInt), uint, double, bool, string, bytes (b"..."), null, list, map, optional, timestamp, duration
| Category | Operators |
|---|---|
| Arithmetic | + - * / % ** (unary -) |
| Comparison | == != < <= > >= |
| Logical | && || ! |
| Membership | in |
| Ternary | ? : |
| Field access | . ?. [] |
| Optional | ?.field [?index] |
contains(), startsWith(), endsWith(), matches(), size(), toLowerCase() / lowerAscii(), toUpperCase() / upperAscii(), trim(), split(), substring(), indexOf(), lastIndexOf(), charAt(), replace(), join(), format()
size(), type(), int(), uint(), double(), string(), bool(), bytes(), timestamp(), duration(), dyn()
exists(), all(), exists_one(), filter(), map(), has(), cel.bind()
optional.of(), optional.none(), .hasValue(), .orValue(), optional.ofNonZero() / optional.ofNonZeroValue()
getFullYear(), getMonth(), getDayOfMonth(), getDayOfWeek(), getDayOfYear(), getHours(), getMinutes(), getSeconds(), getMilliseconds()
getHours(), getMinutes(), getSeconds(), getMilliseconds()
math.greatest(), math.least(), math.max(), math.min(), math.abs(), math.ceil(), math.floor(), math.round(), math.trunc(), math.sign(), math.isNaN(), math.isInf(), math.isFinite(), math.bitAnd(), math.bitOr(), math.bitXor(), math.bitNot(), math.bitShiftLeft(), math.bitShiftRight()
strings.quote()
Double-quoted, single-quoted, triple-quoted (multiline), raw strings (r"..."), byte strings (b"..."), full escape sequences (\n, \t, \xHH, \uHHHH, \UHHHHHHHH, octal)
Measured on Apple M1 Pro, Bun 1.3.11, 100K iterations per case. All cel-vm timings are for bytecode evaluation only (pre-compiled).
| Expression | cel-vm | cel-js | Speedup |
|---|---|---|---|
1 + 2 * 3 |
1,861K ops/s | 96K ops/s | 19× |
(x + y) * z |
1,600K ops/s | 52K ops/s | 31× |
x > 100 && y < 50 |
1,335K ops/s | 56K ops/s | 24× |
x > 0 ? "pos" : "neg" |
1,266K ops/s | 50K ops/s | 25× |
[1, 2, 3, 4, 5] |
1,443K ops/s | 31K ops/s | 47× |
list[2] |
1,694K ops/s | 74K ops/s | 23× |
m.x |
1,738K ops/s | 123K ops/s | 14× |
l.exists(v, v > 3) |
598K ops/s | 37K ops/s | 16× |
l.filter(v, v % 2 == 0) |
395K ops/s | 33K ops/s | 12× |
l.map(v, v * 2) |
735K ops/s | 38K ops/s | 19× |
x > 0 && y > 0 && x + y < 100 |
1,116K ops/s | 34K ops/s | 33× |
Average: 24× faster. Pre-compiled bytecode is an additional 5.6× faster than compile-and-evaluate.
# Run the benchmark yourself
bun run bench/compare.js1434 / 1583 tests passing (90.6%). 911 / 1060 cel-spec conformance tests (86.0%). All 315 marcbachmann/cel-js compatibility tests pass (100%).
149 cel-spec tests are skipped (proto messages, enums, and other features that require protobuf schema support). See docs/plans/2026-04-08-006-feat-skipped-test-gap-analysis-plan.md for the full breakdown and implementation roadmap.
| Area | cel-vm | cel-spec | Reason |
|---|---|---|---|
| Proto messages / enums | Plain JS objects only | Proto schema types | No protobuf schema in JS runtimes |
| uint negation/underflow | Not detected | Error | uint and int are both BigInt at runtime — type distinction lost after compilation. -(42u) and 0u - 1u silently produce negative BigInts |
| Regex | JS RegExp |
RE2 | No native RE2 in JS; minor semantic differences possible |
| Timestamps | Full support | Full support | Millisecond precision; nanosecond precision not yet implemented |
| Bytes literals | b"..." → Uint8Array |
Full support | \u/\U escapes in bytes produce raw charCodeAt instead of UTF-8 encoding |
| Bytes comparison | Not supported | <, >, <=, >= |
Bytes ordering operators not implemented |
| Type identifiers | type() returns string |
First-class type values | bool, int, etc. are not resolvable as identifiers |
The pipeline is strictly sequential: source → Lexer → Parser → Checker → Compiler → Bytecode → VM.
src/lexer.js — hand-written tokeniser
src/parser.js — recursive-descent, plain-object AST
src/checker.js — macro expansion + security validation
src/compiler.js — AST → bytecode; constant folding, variable pre-resolution
src/bytecode.js — binary encode/decode, Base64 serialisation
src/vm.js — while/switch dispatch loop (plain function, not a class)
src/environment.js — Environment class (custom functions, constants, variable declarations)
src/index.js — public API
See IMPLEMENTATION.md for detailed design decisions.
# Run all tests
bun test
# Run a single test file
bun test test/marcbachmann/arithmetic.test.js
# Run tests matching a pattern
bun test --test-name-pattern "string literals"
# Benchmark
bun run bench/compare.jsTests are written with Node's built-in node:test. No test framework dependencies.
GNU GPLv3