A high-performance, zero-dependency implementation of the Common Expression Language (CEL) in JavaScript.
🚀 Use the CEL JS Playground to test expressions.
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
- 🚀 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
npm install @marcbachmann/cel-jsimport{evaluate}from'@marcbachmann/cel-js'// Simple evaluationevaluate('1 + 2 * 3')// 7n// With contextconstallowed=evaluate('user.age >= 18 && "admin" in user.roles',{user: {age: 30,roles: ['admin','user']}})// trueimport{evaluate,parse}from'@marcbachmann/cel-js'// Direct evaluationevaluate('1 + 2')// 3n// With variablesevaluate('name + "!"',{name: 'Alice'})// "Alice!"// Parse once, evaluate multiple times for better performanceconstexpr=parse('user.age >= minAge')expr({user: {age: 25},minAge: 18})// trueexpr({user: {age: 16},minAge: 18})// false// Access parsed AST and type checkingconsole.log(expr.ast)// AST representationconsttypeCheck=expr.check()// Type check without evaluationFor type-safe expressions with custom functions and operators:
import{Environment}from'@marcbachmann/cel-js'classUser{constructor({email, age}){this.email=emailthis.age=age}}constenv=newEnvironment().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)))// Type-checked evaluation with constantenv.evaluate('skipAgeCheck || (isAdult(user.age) && (user.age >= minAge))',{user: newUser({age: 25n}),skipAgeCheck: true})// Custom operatorsenv.evaluate('"Hi" * 3')// "HiHiHi"Use registerConstant to expose shared configuration without passing it through every evaluation context.
import{Environment}from'@marcbachmann/cel-js'constenv=newEnvironment().registerConstant('minAge','int',18n)env.evaluate('user.age >= minAge',{user: {age: 20n}})// trueSupported signatures:
env.registerConstant('minAge','int',18n)env.registerConstant({name: 'minAge',type: 'int',value: 18n,description: 'Minimum age'})newEnvironment({// Treat undeclared variables as dynamic typeunlistedVariablesAreDyn: false,// Require list/map literals to stay strictly homogeneous (default: true)homogeneousAggregateLiterals: true,// Enable .?key/.[?key] optional chaining and optional.* helpers (default: false)enableOptionalTypes: true,// Optional structural limits (parse time)limits: {maxAstNodes: 100000,maxDepth: 250,maxListElements: 1000,maxMapEntries: 1000,maxCallArguments: 32}})- Set
homogeneousAggregateLiteralstofalseif you need aggregate literals to accept mixed element/key/value types without wrapping everything indyn(...). - Set
enableOptionalTypestotrueto activate optional chaining.
registerVariable(name, type)- Declare a variable with type checkingregisterType(typename, constructor)- Register custom typesregisterFunction(signature, handler)- Add custom functionsregisterOperator(signature, handler)- Add custom operatorsregisterConstant(name, type, value)- Provide immutable values without passing them in contextclone()- Create an isolated copy. Call that stops the parent from registering more entries.hasVariable(name)- Check if variable is registeredparse(expression)- Parse expression for reuseevaluate(expression, context)- Evaluate with contextcheck(expression)- Validate expression types without evaluationgetDefinitions()- Returns all registered variables and functions with their types, signatures, and descriptions
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'})// Passing a schema property will implicitly create a custom type and// convert objects/maps to a new class instance. Those values then behave similar like// explicit types that are created using a constructor.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.
// Name + constructor class// when fields are not provided, own properties are accessible automaticallyenv.registerType('Vector',Vector)// Name + object with constructor and field typesenv.registerType('Vector',{ctor: Vector,fields: {x: 'double',y: 'double'}})// Name + object with fields only (auto-generates a wrapper class and convert function)env.registerType('Vector',{fields: {x: 'double',y: 'double'}})// Name + object with nested schema (registers nested types automatically)// But this type can only be used during variable registration.env.registerType('Vector',{schema: {x: 'double',y: 'double'}})// Single object with name and schemaenv.registerType({name: 'Vector',schema: {x: 'double',y: 'double'}})// Single object with constructor (name inferred from constructor)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.
// Signature string + handlerenv.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'})// Single object with signature stringenv.registerFunction({signature: 'add(int, int): int', handler,description: 'Adds two integers'})// Single object with signature string and named paramsenv.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'}]})// Single object without signature stringenv.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'}]})// Receiver method (called as 'hello'.shout())env.registerFunction({name: 'shout',receiverType: 'string',returnType: 'string',handler: (str)=>str.toUpperCase()+'!',params: []})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:
constenv=newEnvironment().registerFunction('fetchUser(string): map',async(id)=>{constres=awaitfetch(`/users/${id}`)returnres.json()})constuser=awaitenv.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.
importassertfrom'node:assert/strict'constparent=newEnvironment().registerVariable('user','map')constchild=parent.clone()// Parent registries is frozen once clonedassert.throws(()=>parent.registerVariable('foo','dyn'))// Child stays fully extensible without deep-copy overheadchild.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
Validate expressions before evaluation to catch type errors early:
import{Environment,TypeError}from'@marcbachmann/cel-js'constenv=newEnvironment().registerVariable('age','int').registerVariable('name','string')// Check expression validityconstresult=env.check('age >= 18 && name.startsWith("A")')if(result.valid){console.log(`Expression is valid, returns: ${result.type}`)// bool// Safe to evaluateconstvalue=env.evaluate('age >= 18 && name.startsWith("A")',{age: 25n,name: 'Alice'})}else{console.error(`Type error: ${result.error.message}`)}// Detect errors without evaluationconstinvalid=env.check('age + name')// Invalid: can't add int + stringconsole.log(invalid.valid)// falseconsole.log(invalid.error.message)// "Operator '+' not defined for types 'int' and 'string'"Benefits:
- Catch type mismatches before runtime
- Validate user-provided expressions safely
- Get inferred return types for expressions
- Better error messages with source location
// Arithmeticevaluate('10 + 5 - 3')// 12nevaluate('10 * 5 / 2')// 25nevaluate('10 % 3')// 1n// Comparisonevaluate('5 > 3')// trueevaluate('5 >= 5')// trueevaluate('5 == 5')// trueevaluate('5 != 4')// true// Logicalevaluate('true && false')// falseevaluate('true || false')// trueevaluate('!false')// true// Ternaryevaluate('5 > 3 ? "yes" : "no"')// "yes"// Membershipevaluate('2 in [1, 2, 3]')// trueevaluate('"ell" in "hello"')// true// Numbers (default to BigInt)evaluate('42')// 42nevaluate('3.14')// 3.14evaluate('0xFF')// 255n// Stringsevaluate('"hello"')// "hello"evaluate('r"\\n"')// "\\n" (raw string)evaluate('"""multi\nline"""')// "multi\nline\n"// Bytesevaluate('b"hello"')// Uint8Arrayevaluate('b"\\xFF"')// Uint8Array [255]// Collectionsevaluate('[1, 2, 3]')// [1n, 2n, 3n]evaluate('{name: "Alice"}')// {name: "Alice"}// Otherevaluate('true')// trueevaluate('null')// null// Type conversionevaluate('string(123)')// "123"evaluate('int("42")')// 42nevaluate('double("3.14")')// 3.14evaluate('bytes("hello")')// Uint8Arrayevaluate('dyn(42)')// Converts to dynamic type// Collectionsevaluate('size([1, 2, 3])')// 3nevaluate('size("hello")')// 5nevaluate('size({a: 1, b: 2})')// 2n// Timeevaluate('timestamp("2024-01-01T00:00:00Z")')// Date// Type checkingevaluate('type(42)')// intevaluate('type("hello")')// stringevaluate('"hello".contains("ell")')// trueevaluate('"hello".startsWith("he")')// trueevaluate('"hello".endsWith("lo")')// trueevaluate('"hello".matches("h.*o")')// trueevaluate('"hello".size()')// 5nevaluate('"hello".indexOf("ll")')// 2nevaluate('"hello world".indexOf("o", 5)')// 7n (search from index 5)evaluate('"hello".lastIndexOf("l")')// 3nevaluate('"hello".substring(1)')// "ello"evaluate('"hello".substring(1, 4)')// "ell"evaluate('[1, 2, 3].size()')// 3nevaluate('["a", "b", "c"].join()')// "abc"evaluate('["a", "b", "c"].join(", ")')// "a, b, c"evaluate('b"hello".size()')// 5nevaluate('b"hello".string()')// "hello"evaluate('b"hello".hex()')// "68656c6c6f"evaluate('b"hello".base64()')// "aGVsbG8="evaluate('b"{\\"x\\": 42}".json()')// {x: 42n}evaluate('b"hello".at(0)')// 104n (byte value at index)All timestamp methods support an optional timezone parameter (e.g., "America/New_York", "UTC"):
constctx={t: newDate('2024-01-15T14:30:45.123Z')}evaluate('t.getFullYear()',ctx)// 2024nevaluate('t.getMonth()',ctx)// 0n (January, 0-indexed)evaluate('t.getDayOfMonth()',ctx)// 15nevaluate('t.getDayOfWeek()',ctx)// 1n (Monday, 0=Sunday)evaluate('t.getDayOfYear()',ctx)// 15nevaluate('t.getHours()',ctx)// 14nevaluate('t.getMinutes()',ctx)// 30nevaluate('t.getSeconds()',ctx)// 45nevaluate('t.getMilliseconds()',ctx)// 123n// With timezoneevaluate('t.getHours("America/New_York")',ctx)// 9n (UTC-5)constctx={numbers: [1,2,3,4,5],users: [{name: 'Alice',admin: true},{name: 'Bob',admin: false}]}// Check property existsevaluate('has(user.email)',{user: {}})// false// All elements matchevaluate('numbers.all(n, n > 0)',ctx)// true// Any element matchesevaluate('numbers.exists(n, n > 3)',ctx)// true// Exactly one matchesevaluate('numbers.exists_one(n, n == 3)',ctx)// true// Transformevaluate('numbers.map(n, n * 2)',ctx)// [2n, 4n, 6n, 8n, 10n]// Filterevaluate('numbers.filter(n, n > 2)',ctx)// [3n, 4n, 5n]// Filter + Transformevaluate('users.filter(u, u.admin).map(u, u.name)',ctx)// Bind a temporary value within the expressionevaluate('cel.bind(total, users.map(u, u.admin, u.score).sum(), total >= 90)',ctx)// Or using three arg form of .mapevaluate('users.map(u, u.admin, u.name)',ctx)// ["Alice"]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'constenv=newEnvironment()env.registerFunction('macro(ast): dyn',({ast, args})=>{// Any parameter on this object are available as// the `macro` parameter within the `typeCheck` and `evaluate` functions below.return{// e.g. you can precompute values during parse timefirstArgument: args[0],// Mandatory: called when the expression is type-checkedtypeCheck(checker,macro,ctx){returnchecker.check(macro.firstArgument,ctx)},// Mandatory: called when the expression is evaluatedevaluate(evaluator,macro,ctx){returnevaluator.run(macro.firstArgument,ctx)}}})import{Environment}from'@marcbachmann/cel-js'classVector{constructor(x,y){this.x=xthis.y=y}add(other){returnnewVector(this.x+other.x,this.y+other.y)}}constenv=newEnvironment().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))constresult=env.evaluate('magnitude(v1 + v2)',{v1: newVector(3,4),v2: newVector(1,2)})// 7.211102550927978There 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.
import{Environment,evaluate,ParseError,EvaluationError,TypeError}from'@marcbachmann/cel-js'try{evaluate('invalid + + syntax')}catch(error){if(errorinstanceofParseError){console.error('Syntax error:',error.code,error.range,error.summary)console.error(error.message)// Includes source highlighting for humans}elseif(errorinstanceofEvaluationError){console.error('Runtime error:',error.code,error.range,error.summary)}}// Type checking returns errors without throwingconstenv=newEnvironment().registerVariable('x','int')constresult=env.check('x + "string"')if(!result.valid){consterror=result.errorconsole.error('Type error:',error.code,error.range)}import{Environment}from'@marcbachmann/cel-js'// Instantiating an environment is expensive, please do that outside hot code pathsconstauthEnv=newEnvironment().registerVariable('user','map').registerVariable('resource','map')constcanEdit=authEnv.parse(` user.isActive && (user.role == "admin" || user.id == resource.ownerId)`)canEdit({user: {id: 123,role: 'user',isActive: true},resource: {ownerId: 123}})// trueimport{Environment}from'@marcbachmann/cel-js'// Instantiating an environment is expensive, please do that outside hot code pathsconstvalidator=newEnvironment().registerVariable('email','string').registerVariable('age','int').registerFunction('isValidEmail(string): bool',email=>/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))constvalid=validator.evaluate('isValidEmail(email) && age >= 18 && age < 120',{email: 'user@example.com',age: 25n})import{parse}from'@marcbachmann/cel-js'constflags={'new-dashboard': parse('user.betaUser || user.id in allowedUserIds'),'premium-features': parse('user.subscription == "pro" && !user.trialExpired')}functionisEnabled(feature,context){returnflags[feature]?.(context)??false}Full TypeScript support included:
import{Environment,ParseError}from'@marcbachmann/cel-js'// Instantiating an environment is expensive, please do that outside hot code pathsconstenv=newEnvironment().registerVariable('count','int').registerFunction('multiplyByTwo(int): int',(x)=>x*2n)constresult: any=env.evaluate('multiplyByTwo(count)',{count: 21n})This library is a drop-in-spirit replacement for the cel-js package (by ChromeGG) with full CEL spec coverage and ~10x better performance.
cel-js | @marcbachmann/cel-js | |
|---|---|---|
| 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 |
// Beforeimport{evaluate}from'cel-js'evaluate('user.role == "admin"',{user: {role: 'admin'}})// Afterimport{evaluate}from'@marcbachmann/cel-js'evaluate('user.role == "admin"',{user: {role: 'admin'}})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.
// Beforeimport{evaluate,parse}from'cel-js'constresult=parse('2 + a')if(!result.isSuccess)thrownewError('Expression failed to parse')constvalue=evaluate(result.cst,{a: 2})// Afterimport{parse,ParseError}from'@marcbachmann/cel-js'// Throws ParseError if the expression is not validconstcompiled=parse('2 + a')// Throws EvaluationError if the evaluation failsconstvalue=compiled({a: 2n})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.
// Beforeimport{evaluate}from'cel-js'evaluate('greet(name)',{name: 'Alice'},{greet: (name)=>`Hello, ${name}!`})// Afterimport{Environment}from'@marcbachmann/cel-js'constenv=newEnvironment({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.
cel-js treats all variables as dynamic and allows mixed-type lists and maps by default. To replicate that behavior:
constenv=newEnvironment({unlistedVariablesAreDyn: true,// allow undeclared variableshomogeneousAggregateLiterals: false// allow mixed-type list/map literals})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.
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.
// Beforeevaluate('count + 1',{count: 5})// => 6// Afterevaluate('count + 1',{count: 5n})// => 6nFloating-point (double) values remain plain JS number in both libraries.
Contributions welcome! Please open an issue before submitting major changes.
# Run tests
npm test# Run benchmarks
npm run benchmark
# Run in watch mode
npm run test:watchMIT © Marc Bachmann