@marcbachmann/cel-js

A high-performance, zero-dependency implementation of the Common Expression Language (CEL) in JavaScript.
🚀 Use the CEL JS Playground to test expressions.
Overview
CEL (Common Expression Language) is a non-Turing complete language designed for simplicity, speed, safety, and portability. This JavaScript implementation provides a fast, lightweight CEL evaluator perfect for policy evaluation, configuration, and embedded expressions.
To migrate from cel-js to @marcbachmann/cel-js, please see Migrating from cel-js
Features
- 🚀 Zero Dependencies - No external packages required
- ⚡ High Performance - About 10x faster than alternatives (compared to cel-js)
- 📦 ES Modules - Modern ESM with full tree-shaking support
- 🔒 Type Safe - Environment API with type checking for variables, custom types and functions
- 🎯 Most of the CEL Spec - Including macros, custom functions and types, optional chaining, input variables, and all operators
- 📘 TypeScript Support - Full type definitions included
Installation
npm install @marcbachmann/cel-js
Quick Start
import {evaluate} from '@marcbachmann/cel-js'
evaluate('1 + 2 * 3')
const allowed = evaluate(
'user.age >= 18 && "admin" in user.roles',
{user: {age: 30, roles: ['admin', 'user']}}
)
API
Simple Evaluation
import {evaluate, parse} from '@marcbachmann/cel-js'
evaluate('1 + 2')
evaluate('name + "!"', {name: 'Alice'})
const expr = parse('user.age >= minAge')
expr({user: {age: 25}, minAge: 18})
expr({user: {age: 16}, minAge: 18})
console.log(expr.ast)
const typeCheck = expr.check()
Environment API (Recommended)
For type-safe expressions with custom functions and operators:
import {Environment} from '@marcbachmann/cel-js'
class User {
constructor ({email, age}) {
this.email = email
this.age = age
}
}
const env = new Environment()
.registerType('User', {
fields: {
email: 'string',
age: 'int'
},
ctor: User
})
.registerVariable('skipAgeCheck', 'bool')
.registerVariable('user', 'User')
.registerConstant('minAge', 'int', 18n)
.registerFunction('isAdult(int): bool', age => age >= 18n)
.registerOperator('string * int', (str, n) => str.repeat(Number(n)))
env.evaluate(
'skipAgeCheck || (isAdult(user.age) && (user.age >= minAge))', {
user: new User({age: 25n}),
skipAgeCheck: true
})
env.evaluate('"Hi" * 3')
Register constants
Use registerConstant to expose shared configuration without passing it through every evaluation context.
import {Environment} from '@marcbachmann/cel-js'
const env = new Environment()
.registerConstant('minAge', 'int', 18n)
env.evaluate('user.age >= minAge', {user: {age: 20n}})
Supported signatures:
env.registerConstant('minAge', 'int', 18n)
env.registerConstant({name: 'minAge', type: 'int', value: 18n, description: 'Minimum age'})
Environment Options
new Environment({
unlistedVariablesAreDyn: false,
homogeneousAggregateLiterals: true,
enableOptionalTypes: true,
limits: {
maxAstNodes: 100000,
maxDepth: 250,
maxListElements: 1000,
maxMapEntries: 1000,
maxCallArguments: 32
}
})
- Set
homogeneousAggregateLiterals to false if you need aggregate literals to accept mixed element/key/value types without wrapping everything in dyn(...).
- Set
enableOptionalTypes to true to activate optional chaining.
Environment Methods
registerVariable(name, type) - Declare a variable with type checking
registerType(typename, constructor) - Register custom types
registerFunction(signature, handler) - Add custom functions
registerOperator(signature, handler) - Add custom operators
registerConstant(name, type, value) - Provide immutable values without passing them in context
clone() - Create an isolated copy. Call that stops the parent from registering more entries.
hasVariable(name) - Check if variable is registered
parse(expression) - Parse expression for reuse
evaluate(expression, context) - Evaluate with context
check(expression) - Validate expression types without evaluation
getDefinitions() - Returns all registered variables and functions with their types, signatures, and descriptions
registerVariable signatures
env.registerVariable('user', 'map')
env.registerVariable('user', 'map', {description: 'The current user'})
env.registerVariable('user', {type: 'map', description: 'The current user'})
env.registerVariable({name: 'user', type: 'map', description: 'The current user'})
env.registerVariable({
name: 'user',
schema: {
email: 'string',
age: 'int',
profile: {
tags: 'list<string>',
avatar: 'string'
}
}
})
The type can be a type string (e.g. 'int', 'map', 'list<string>') or a TypeDeclaration obtained from another environment.
registerType signatures
env.registerType('Vector', Vector)
env.registerType('Vector', {ctor: Vector, fields: {x: 'double', y: 'double'}})
env.registerType('Vector', {fields: {x: 'double', y: 'double'}})
env.registerType('Vector', {schema: {x: 'double', y: 'double'}})
env.registerType({name: 'Vector', schema: {x: 'double', y: 'double'}})
env.registerType({ctor: Vector, fields: {x: 'double', y: 'double'}})
When fields or schema is provided without a ctor, an internal wrapper class is auto-generated and plain objects are automatically converted at runtime. A custom convert function can be passed to override this default conversion.
In that case the type should only be used during variable registration.
When using the schema declaration, we're creating a new Map instance for the specific type when retrieving the values by variable from a context object.
registerFunction signatures
env.registerFunction('greet(string): string', (name) => `Hello, ${name}!`)
env.registerFunction('greet(string): string', handler, {description: 'Greets someone'})
env.registerFunction('greet(string): string', {handler, description: 'Greets someone'})
env.registerFunction({signature: 'add(int, int): int', handler, description: 'Adds two integers'})
env.registerFunction({
signature: 'formatDate(int, string): string',
handler,
description: 'Formats a timestamp',
params: [
{name: 'timestamp', description: 'Unix timestamp in seconds'},
{name: 'format', description: 'Date format string'}
]
})
env.registerFunction({
name: 'multiply',
returnType: 'int',
handler: (a, b) => a * b,
description: 'Multiplies two integers',
params: [
{name: 'a', type: 'int', description: 'First number'},
{name: 'b', type: 'int', description: 'Second number'}
]
})
env.registerFunction({
name: 'shout',
receiverType: 'string',
returnType: 'string',
handler: (str) => str.toUpperCase() + '!',
params: []
})
registerFunction (sync & async)
registerFunction(signature, handler) accepts both synchronous and async handlers. When an async function (or a macro predicate/transform that uses async functions) participates in an expression, env.evaluate() returns a Promise that resolves with the final value. Consumers should await those evaluations when they register async behavior:
const env = new Environment()
.registerFunction('fetchUser(string): map', async (id) => {
const res = await fetch(`/users/${id}`)
return res.json()
})
const user = await env.evaluate('fetchUser(userId)', {userId: '42'})
Async handlers are primarily intended for latency-sensitive lookups (e.g., cache fetches, lightweight RPC). CEL’s goal is still deterministic, predictable evaluation, so avoid building expressions that trigger unbounded async work (like nested loops within macros or large fan-out requests) even though the engine will await those results.
Environment Cloning
import assert from 'node:assert/strict'
const parent = new Environment().registerVariable('user', 'map')
const child = parent.clone()
assert.throws(() => parent.registerVariable('foo', 'dyn'))
child
.registerFunction('isAdult(map): bool', (u) => u.age >= 18n)
.registerVariable('minAge', 'int')
child.evaluate('isAdult(user) && user.age >= minAge', {
user: {age: 20n},
minAge: 18n
})
Supported Types: int, uint, double, string, bool, bytes, list, map, timestamp, duration, null_type, type, dyn, or custom types
Type Checking
Validate expressions before evaluation to catch type errors early:
import {Environment, TypeError} from '@marcbachmann/cel-js'
const env = new Environment()
.registerVariable('age', 'int')
.registerVariable('name', 'string')
const result = env.check('age >= 18 && name.startsWith("A")')
if (result.valid) {
console.log(`Expression is valid, returns: ${result.type}`)
const value = env.evaluate('age >= 18 && name.startsWith("A")', {
age: 25n,
name: 'Alice'
})
} else {
console.error(`Type error: ${result.error.message}`)
}
const invalid = env.check('age + name')
console.log(invalid.valid)
console.log(invalid.error.message)
Benefits:
- Catch type mismatches before runtime
- Validate user-provided expressions safely
- Get inferred return types for expressions
- Better error messages with source location
Language Features
Operators
evaluate('10 + 5 - 3')
evaluate('10 * 5 / 2')
evaluate('10 % 3')
evaluate('5 > 3')
evaluate('5 >= 5')
evaluate('5 == 5')
evaluate('5 != 4')
evaluate('true && false')
evaluate('true || false')
evaluate('!false')
evaluate('5 > 3 ? "yes" : "no"')
evaluate('2 in [1, 2, 3]')
evaluate('"ell" in "hello"')
Data Types
evaluate('42')
evaluate('3.14')
evaluate('0xFF')
evaluate('"hello"')
evaluate('r"\\n"')
evaluate('"""multi\nline"""')
evaluate('b"hello"')
evaluate('b"\\xFF"')
evaluate('[1, 2, 3]')
evaluate('{name: "Alice"}')
evaluate('true')
evaluate('null')
Built-in Functions
evaluate('string(123)')
evaluate('int("42")')
evaluate('double("3.14")')
evaluate('bytes("hello")')
evaluate('dyn(42)')
evaluate('size([1, 2, 3])')
evaluate('size("hello")')
evaluate('size({a: 1, b: 2})')
evaluate('timestamp("2024-01-01T00:00:00Z")')
evaluate('type(42)')
evaluate('type("hello")')
String Methods
evaluate('"hello".contains("ell")')
evaluate('"hello".startsWith("he")')
evaluate('"hello".endsWith("lo")')
evaluate('"hello".matches("h.*o")')
evaluate('"hello".size()')
evaluate('"hello".indexOf("ll")')
evaluate('"hello world".indexOf("o", 5)')
evaluate('"hello".lastIndexOf("l")')
evaluate('"hello".substring(1)')
evaluate('"hello".substring(1, 4)')
List Methods
evaluate('[1, 2, 3].size()')
evaluate('["a", "b", "c"].join()')
evaluate('["a", "b", "c"].join(", ")')
Bytes Methods
evaluate('b"hello".size()')
evaluate('b"hello".string()')
evaluate('b"hello".hex()')
evaluate('b"hello".base64()')
evaluate('b"{\\"x\\": 42}".json()')
evaluate('b"hello".at(0)')
Timestamp Methods
All timestamp methods support an optional timezone parameter (e.g., "America/New_York", "UTC"):
const ctx = {t: new Date('2024-01-15T14:30:45.123Z')}
evaluate('t.getFullYear()', ctx)
evaluate('t.getMonth()', ctx)
evaluate('t.getDayOfMonth()', ctx)
evaluate('t.getDayOfWeek()', ctx)
evaluate('t.getDayOfYear()', ctx)
evaluate('t.getHours()', ctx)
evaluate('t.getMinutes()', ctx)
evaluate('t.getSeconds()', ctx)
evaluate('t.getMilliseconds()', ctx)
evaluate('t.getHours("America/New_York")', ctx)
Macros
const ctx = {
numbers: [1, 2, 3, 4, 5],
users: [
{name: 'Alice', admin: true},
{name: 'Bob', admin: false}
]
}
evaluate('has(user.email)', {user: {}})
evaluate('numbers.all(n, n > 0)', ctx)
evaluate('numbers.exists(n, n > 3)', ctx)
evaluate('numbers.exists_one(n, n == 3)', ctx)
evaluate('numbers.map(n, n * 2)', ctx)
evaluate('numbers.filter(n, n > 2)', ctx)
evaluate('users.filter(u, u.admin).map(u, u.name)', ctx)
evaluate('cel.bind(total, users.map(u, u.admin, u.score).sum(), total >= 90)', ctx)
evaluate('users.map(u, u.admin, u.name)', ctx)
Custom macros
You can register your own macros by declaring overloads that accept ast arguments. The macro handler executes at parse time and must return an object that provides both typeCheck and evaluate hooks; these hooks are invoked later during env.check() and env.evaluate() so the macro lines up with the regular type-checker/evaluator pipeline.
import {Environment} from '@marcbachmann/cel-js'
const env = new Environment()
env.registerFunction('macro(ast): dyn', ({ast, args}) => {
return {
firstArgument: args[0],
typeCheck(checker, macro, ctx) {
return checker.check(macro.firstArgument, ctx)
},
evaluate(evaluator, macro, ctx) {
return evaluator.run(macro.firstArgument, ctx)
}
}
})
Custom Types
import {Environment} from '@marcbachmann/cel-js'
class Vector {
constructor(x, y) {
this.x = x
this.y = y
}
add(other) {
return new Vector(this.x + other.x, this.y + other.y)
}
}
const env = new Environment()
.registerType('Vector', Vector)
.registerVariable('v1', 'Vector')
.registerVariable('v2', 'Vector')
.registerOperator('Vector + Vector', (a, b) => a.add(b))
.registerFunction('magnitude(Vector): double', (v) =>
Math.sqrt(v.x * v.x + v.y * v.y)
)
const result = env.evaluate('magnitude(v1 + v2)', {
v1: new Vector(3, 4),
v2: new Vector(1, 2)
})
Performance
There are a few expressions compared with the cel-js module in ./benchmark/comparison.js where @marcbachmann/cel-js is about 10x faster in average.
Benchmark results comparing against the cel-js package on Node.js v24.13.1(Macbook Air, Apple Silicon M3).
$ ./benchmark/comparison.js
marcbachmann parse (variable lookups) x 6,421,456 ops/sec (11 runs sampled) min..max=(154.29ns...158.92ns)
chromeGG/cel parse (variable lookups) x 579,655 ops/sec (11 runs sampled) min..max=(1.69us...1.77us)
marcbachmann evaluate (variable lookups) x 15,595,918 ops/sec (11 runs sampled) min..max=(63.53ns...65.66ns)
chromeGG/cel evaluate (variable lookups) x 1,018,302 ops/sec (10 runs sampled) min..max=(972.24ns...991.29ns)
marcbachmann parse (Complex Arithmetic) x 2,269,704 ops/sec (11 runs sampled) min..max=(437.22ns...446.92ns)
chromeGG/cel parse (Complex Arithmetic) x 240,077 ops/sec (9 runs sampled) min..max=(4.09us...4.23us)
marcbachmann evaluate (Complex Arithmetic) x 213,800,679 ops/sec (11 runs sampled) min..max=(4.36ns...4.97ns)
chromeGG/cel evaluate (Complex Arithmetic) x 653,270 ops/sec (10 runs sampled) min..max=(1.51us...1.55us)
marcbachmann parse (check container ports) x 485,218 ops/sec (10 runs sampled) min..max=(2.05us...2.07us)
chromeGG/cel parse (check container ports) x 81,485 ops/sec (11 runs sampled) min..max=(10.67us...16.45us)
marcbachmann evaluate (check container ports) x 2,308,649 ops/sec (11 runs sampled) min..max=(428.87ns...437.01ns)
chromeGG/cel evaluate (check container ports) x 218,715 ops/sec (10 runs sampled) min..max=(4.44us...4.74us)
marcbachmann parse (check jwt claims) x 509,006 ops/sec (10 runs sampled) min..max=(1.95us...1.98us)
chromeGG/cel parse (check jwt claims) x 89,792 ops/sec (11 runs sampled) min..max=(11.03us...11.22us)
marcbachmann evaluate (check jwt claims) x 1,751,764 ops/sec (11 runs sampled) min..max=(568.17ns...574.57ns)
chromeGG/cel evaluate (check jwt claims) x 156,000 ops/sec (10 runs sampled) min..max=(6.30us...6.56us)
marcbachmann parse (access log filtering) x 1,237,634 ops/sec (9 runs sampled) min..max=(803.71ns...809.17ns)
chromeGG/cel parse (access log filtering) x 205,173 ops/sec (11 runs sampled) min..max=(4.81us...5.10us)
marcbachmann evaluate (access log filtering) x 3,973,434 ops/sec (11 runs sampled) min..max=(250.21ns...255.54ns)
chromeGG/cel evaluate (access log filtering) x 432,020 ops/sec (11 runs sampled) min..max=(2.26us...2.44us)
To run the benchmarks against previous versions of this module, you can run ./benchmark/index.js.
Error Handling
import {Environment, evaluate, ParseError, EvaluationError, TypeError} from '@marcbachmann/cel-js'
try {
evaluate('invalid + + syntax')
} catch (error) {
if (error instanceof ParseError) {
console.error('Syntax error:', error.code, error.range, error.summary)
console.error(error.message)
} else if (error instanceof EvaluationError) {
console.error('Runtime error:', error.code, error.range, error.summary)
}
}
const env = new Environment().registerVariable('x', 'int')
const result = env.check('x + "string"')
if (!result.valid) {
const error = result.error
console.error('Type error:', error.code, error.range)
}
Examples
Authorization Rules
import {Environment} from '@marcbachmann/cel-js'
const authEnv = new Environment()
.registerVariable('user', 'map')
.registerVariable('resource', 'map')
const canEdit = authEnv.parse(`
user.isActive &&
(user.role == "admin" ||
user.id == resource.ownerId)
`)
canEdit({
user: {id: 123, role: 'user', isActive: true},
resource: {ownerId: 123}
})
Data Validation
import {Environment} from '@marcbachmann/cel-js'
const validator = new Environment()
.registerVariable('email', 'string')
.registerVariable('age', 'int')
.registerFunction('isValidEmail(string): bool',
email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
)
const valid = validator.evaluate(
'isValidEmail(email) && age >= 18 && age < 120',
{email: 'user@example.com', age: 25n}
)
Feature Flags
import {parse} from '@marcbachmann/cel-js'
const flags = {
'new-dashboard': parse(
'user.betaUser || user.id in allowedUserIds'
),
'premium-features': parse(
'user.subscription == "pro" && !user.trialExpired'
)
}
function isEnabled(feature, context) {
return flags[feature]?.(context) ?? false
}
TypeScript
Full TypeScript support included:
import {Environment, ParseError} from '@marcbachmann/cel-js'
const env = new Environment()
.registerVariable('count', 'int')
.registerFunction('multiplyByTwo(int): int', (x) => x * 2n)
const result: any = env.evaluate('multiplyByTwo(count)', {count: 21n})
Migrating from cel-js
This library is a drop-in-spirit replacement for the cel-js package (by ChromeGG) with full CEL spec coverage and ~10x better performance.
Key differences
| Package | cel-js | @marcbachmann/cel-js |
evaluate() args | (expr, vars, functions) | (expr, vars) — 2 args only |
parse() result | {isSuccess, errors, cst} | throws ParseError on failure, returns a callable |
| Reusing parsed expr | evaluate(result.cst, vars) | compiled(vars) |
| Custom functions | 3rd arg to evaluate() | env.registerFunction(signature, handler) |
| Integer values | plain number | BigInt (e.g. 42n) |
| Floating-point values | plain number | plain number (unchanged) |
| Undeclared variables | always allowed | requires unlistedVariablesAreDyn: true |
| Mixed-type list/map literals | always allowed | requires homogeneousAggregateLiterals: false |
evaluate() — no change for simple use
import { evaluate } from 'cel-js'
evaluate('user.role == "admin"', { user: { role: 'admin' } })
import { evaluate } from '@marcbachmann/cel-js'
evaluate('user.role == "admin"', { user: { role: 'admin' } })
parse() — result is now a callable
cel-js returned {isSuccess, errors, cst} and required passing cst back to evaluate(). Now parse() throws a ParseError on invalid syntax and returns a compiled, directly callable function.
import { evaluate, parse } from 'cel-js'
const result = parse('2 + a')
if (!result.isSuccess) throw new Error('Expression failed to parse')
const value = evaluate(result.cst, { a: 2 })
import { parse, ParseError } from '@marcbachmann/cel-js'
const compiled = parse('2 + a')
const value = compiled({ a: 2n })
Custom functions — use Environment
cel-js accepted a functions object as the third argument to evaluate(). This library uses an Environment instead, which also unlocks type safety, reuse, and better performance.
import { evaluate } from 'cel-js'
evaluate('greet(name)', { name: 'Alice' }, {
greet: (name) => `Hello, ${name}!`
})
import { Environment } from '@marcbachmann/cel-js'
const env = new Environment({ unlistedVariablesAreDyn: true })
.registerFunction('greet(string): string', (name) => `Hello, ${name}!`)
env.evaluate('greet(name)', { name: 'Alice' })
Create the Environment once outside of hot code paths and reuse it — parsing and environment setup are the expensive parts.
unlistedVariablesAreDyn and homogeneousAggregateLiterals
cel-js treats all variables as dynamic and allows mixed-type lists and maps by default. To replicate that behavior:
const env = new Environment({
unlistedVariablesAreDyn: true,
homogeneousAggregateLiterals: false
})
Without unlistedVariablesAreDyn: true, all variables must be declared via env.registerVariable() before use. The global evaluate() and parse() functions always behave as if unlistedVariablesAreDyn: true.
Integer values are BigInt
CEL integers are returned as BigInt (42n) instead of plain JS numbers. Pass integer context values as BigInt too, or use unlistedVariablesAreDyn: true to accept plain numbers via coercion.
evaluate('count + 1', { count: 5 })
evaluate('count + 1', { count: 5n })
Floating-point (double) values remain plain JS number in both libraries.
Contributing
Contributions welcome! Please open an issue before submitting major changes.
npm test
npm run benchmark
npm run test:watch
License
MIT © Marc Bachmann
See Also