@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.
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.eval(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,491,393 ops/sec (10 runs sampled) min..max=(149.34ns...156.11ns)
chromeGG/cel parse (variable lookups) x 530,216 ops/sec (12 runs sampled) min..max=(1.56us...2.04us)
marcbachmann evaluate (variable lookups) x 14,026,229 ops/sec (10 runs sampled) min..max=(70.35ns...71.76ns)
chromeGG/cel evaluate (variable lookups) x 997,079 ops/sec (10 runs sampled) min..max=(970.67ns...1.01us)
marcbachmann parse (check container ports) x 533,663 ops/sec (11 runs sampled) min..max=(1.86us...1.88us)
chromeGG/cel parse (check container ports) x 64,376 ops/sec (9 runs sampled) min..max=(15.24us...15.91us)
marcbachmann evaluate (check container ports) x 2,121,321 ops/sec (11 runs sampled) min..max=(463.34ns...478.94ns)
chromeGG/cel evaluate (check container ports) x 210,473 ops/sec (10 runs sampled) min..max=(4.51us...5.14us)
marcbachmann parse (check jwt claims) x 554,418 ops/sec (10 runs sampled) min..max=(1.76us...1.81us)
chromeGG/cel parse (check jwt claims) x 60,222 ops/sec (12 runs sampled) min..max=(13.73us...17.89us)
marcbachmann evaluate (check jwt claims) x 1,716,346 ops/sec (11 runs sampled) min..max=(579.29ns...588.51ns)
chromeGG/cel evaluate (check jwt claims) x 152,115 ops/sec (9 runs sampled) min..max=(6.37us...6.57us)
marcbachmann parse (access log filtering) x 1,297,845 ops/sec (11 runs sampled) min..max=(762.15ns...784.65ns)
chromeGG/cel parse (access log filtering) x 127,437 ops/sec (12 runs sampled) min..max=(6.68us...8.28us)
marcbachmann evaluate (access log filtering) x 3,507,039 ops/sec (10 runs sampled) min..max=(281.53ns...286.70ns)
chromeGG/cel evaluate (access log filtering) x 409,620 ops/sec (10 runs sampled) min..max=(2.38us...2.51us)
To run the benchmarks against previous versions of this module, you can run ./benchmark/index.js.
Error Handling
import {evaluate, ParseError, EvaluationError, TypeError} from '@marcbachmann/cel-js'
try {
evaluate('invalid + + syntax')
} catch (error) {
if (error instanceof ParseError) {
console.error('Syntax error:', error.message)
} else if (error instanceof EvaluationError) {
console.error('Runtime error:', error.message)
}
}
const env = new Environment().registerVariable('x', 'int')
const result = env.check('x + "string"')
if (!result.valid && result.error instanceof TypeError) {
console.error('Type error:', result.error.message)
}
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, evaluate, 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})
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