@telorun/cel
v0.116.0
Published
The Common Expression Language for Telo: read, type-check and evaluate an expression, hold it as a tree, write it back.
Readme
@telorun/cel
The Common Expression Language, as a library: read an expression, hold it as a tree, check it, run it, compile it to JavaScript, write it back.
import { parseExpression, qualifiedCalls, rootReferences, serializeTree } from "@telorun/cel";
const expression = parseExpression("Billing.total(cart.items)", { namespaces: ["Billing"] });
expression.diagnostics; // [] — nothing it could not read
rootReferences(expression.root); // ["cart"] — what it reads from its environment
qualifiedCalls(expression.root); // one call: Billing.total, arity 1, with its range
serializeTree(expression.root); // "Billing.total(cart.items)"What it answers
- The whole expression grammar, into a tree whose every node carries the
[start, end]span of source it covers: int64,u-suffixed uint64 and double literals; single-, double- and triple-quoted strings, raw strings,b"…"andbr"…"bytes, with every escape CEL defines;true,false,null; list and map literals; the operator precedence table; member access and index access in both their plain and optional forms (.,.?,[],[?]), a member name between backticks, and a name a dot opens (.y); global calls and receiver calls. - Qualified calls.
Alias.fn(x)andobj.method(x)are the same syntax, so the only thing that can tell them apart is a set of names that denote namespaces. Pass that set asnamespacesand every call on one of those names becomes aqcallnode. An expression records the set it was resolved under, so a consumer can tell whether it is looking at a tree resolved for its own site. - Error recovery. Reading never throws. A malformed or half-typed expression gives back a tree
for the longest prefix that read, plus one diagnostic with the range of what stopped it — a member
with no name yet (
request.) is a select with an empty field, which is what completion after a dot needs. - Round-trip.
serializeTreewrites any tree back as source that reads as an equal expression (treesEqual), parentheses placed from precedence rather than remembered from the text. - Two questions about a tree:
rootReferences— the first name of every access chain, excluding what a comprehension orcel.bindbinds and excluding a namespace; andqualifiedCalls— every call on a namespace, in source order. - Input limits. 100000 nodes, 250 levels of nesting, 1000 list elements, 1000 map entries and 32
call arguments, each reported as an ordinary diagnostic rather than an exception, so hostile input
behaves like any other unreadable input.
DEFAULT_PARSE_LIMITSare those numbers; passlimitsto change them.
Checking
import { CelEnvironment } from "@telorun/cel";
const environment = new CelEnvironment({ enableOptionalTypes: true })
.registerVariable("request", { schema: requestSchema }) // typed to full depth
.registerFunction("sha256(string): string", { hostBacked: true, throws: ["ERR_DIGEST_FAILED"] })
.registerNamespace("Billing", ["total(int, int): int"]);
const result = environment.check("request.query.limti");
result.valid; // false
result.diagnostics; // [{ code: "CEL_UNKNOWN_FIELD", message: "…(declared: limit, tags)", range: [14, 19] }]
result.calls; // which signature each call resolved to, with its metadata
environment.check("request.query.limit").typeName; // "int"- Registration is not privileged. CEL's own library registers through the same
registerFunction, and the dispatch key is a call's name, form and parameter types — not its return type. SoregisterFunction("duration(string): Money")replaces the library'sduration(string), andremoveFunctionsNamed("duration")makes a call to itCEL_UNKNOWN_FUNCTION.clone()inherits everything and then diverges. - JSON Schema is the native input, to full depth — nested objects, element types, unions kept as
unions,
allOfintersected, and a reference followed. A flat field map ({ fields: { columns: "map" } }) is still accepted, for a host that must type exactly as shallowly as something it is replacing. - Nothing a schema says falls through to
dynin silence. A#/$defs/…reference is resolved against the document the node belongs to; one that leaves the document is answered by the host's own resolver, which returns a registered type name or the document to read in place of that node. A node the engine has no rule for is reported by JSON Pointer rather than typeddynquietly —environment.schemaReports(), so the consumer that knows where the schema was written can anchor a diagnostic at that line. A reference re-entered on the descent is a recursive schema: the one deliberatedyn, declared as such. - A named type is not its base.
registerType({ name: "Money", base: "int" })gives a type its own operators, comparisons, conversions, members and invariant type parameters; a plainintis refused at aMoneyslot, andHolder<string>whereHolder<int>is wanted isCEL_TYPE_ARGUMENT_MISMATCH. - Every verdict carries a range and is decided by the checker —
CEL_SYNTAX_ERROR,CEL_TYPE_ERROR,CEL_UNKNOWN_IDENTIFIER,CEL_UNKNOWN_FIELD,CEL_UNKNOWN_FUNCTION,CEL_WRONG_CALL_FORM,CEL_TYPE_ARGUMENT_MISMATCH,CEL_NULLABLE_ACCESS,CEL_INVALID_ARGUMENT, andFUNCTION_UNRESOLVED/FUNCTION_ARITY_MISMATCH/FUNCTION_ARGUMENT_MISMATCHfor a namespaced call. A fix, where one is offered, is the whole corrected source. - A namespaced call is judged only against what you declared.
registerNamespace("Billing", […], { open: true })makes a name you did not declare typedynand report nothing — it is still listed inresult.calls, so you resolve it against your own vocabulary and word that verdict yourself. And a declaration written{ name: "total", returns: "double" }instead of{ signature }withholds the parameter list: the result is typed, the arity and argument types are yours to judge. Both are for a host whose resolution or whose signature grammar is richer than CEL's; neither can be half-declared, since there is no way to supply parameters and ask for them to be ignored. - Three guards clear a read of something that may be null and no others:
?:,&&,||. - The optional library enters whole where
enableOptionalTypesis on:.?,[?],[?x],{?k: v},optional.of,none,ofNonZeroValue,hasValue,value,or,orValue,optMap,optFlatMap, equality over optionals,type()of one, and the type nameoptional_type. - A member whose name is not an identifier is read between backticks —
request.headers.``content-type``, typed against the schema'spropertiesexactly as a plain member is, so a typo in a dashed or dotted key is stillCEL_UNKNOWN_FIELD`. Only where a member is read: anywhere else a backtick is a syntax error. - A dotted declaration is one name. Declare
a.b.c, ora.b, or both:a.b.creads the variable of that name where it is declared and the map's entry where onlya.bis — the longest prefix wins. .yresolves against the environment, never against a name the expression bound: it is the only spelling for an outer name where a comprehension variable shares it.convertsToString(type)answers whether a value of that type can be rendered as text by the environment's ownstring().
The standard library is data — src/signatures/standard-library.json, described in
docs/signature-data.md. It is CEL's library: a declaration CEL itself does
not define carries "spec": false and a reason saying why it is here, where the equivalent lives and how
this member differs from it — and nineteen do.
The function catalog
Beside CEL's own library there is a second one — Telo's dialect, 67 functions over 86 signatures, also
data (src/signatures/function-catalog.json):
import { CelEnvironment, functionCatalog, registerFunctionCatalog } from "@telorun/cel";
const environment = new CelEnvironment({ enableOptionalTypes: true });
registerFunctionCatalog(environment, {
handlers: { sha256: hashHex, json: writeJson, joinPath: (base, rest) => join(base, rest) },
});
environment.evaluate("formatDuration(510, 480)"); // "1d 30m"
functionCatalog(); // the one listing surface: name, signature, category, summary, flags- It registers through the public surface —
registerFunction, once per signature — so it can be left out, replaced function by function, or removed by name. A default environment has none of it. - Nine functions are the host's:
sha256,md5,sha1,sha512,hmac,base64Encode,base64Decode,jsonandjoinPath, each needing a facility this package may not reach. One left out still registers, so a consumer that only checks still type-checks the call; evaluating it answersunbound_functionnaming the function. - A refusal is the catalog's own words,
<function>: <what is wrong>— never a library's wording. An invalid pattern ends with one of RE2's own parse-error kinds and nothing after it, andparseJsonwords its own offset (parseJson: invalid JSON at offset 3). - A literal argument is refused at CHECK, not only when the expression runs:
fixed(1.0, 11)isCEL_INVALID_ARGUMENTnaming the call, because a signature constrains types and a decimal count out of range is a value. Twelve functions carry such a guard, and each runs the very code the evaluation runs. - Two families reach a host facility by declaration — the clock (
now,nowIso,today,nowMillis,nowSeconds) and the random source behind the UUID functions, which is whatdeterministic: falsemeans. Both exist in a browser as in Node.
Evaluating
const program = environment.compile("request.query.limit != null ? int(request.query.limit) : 25");
program.evaluate({ request: { query: { limit: "50" } } }); // 50n
program.evaluate({ request: { query: {} } }); // 25n- Every operator and function behaves as cel-spec requires.
intis abigintand an overflow is an error rather than a wrap;uintanddoubleare distinct types; division and modulus by zero are two different errors; bytes and strings are distinct and both are ordered; a timestamp is nanosecond-precise and its getters take a zone (an IANA name or a fixedHH:MMoffset);matchesis RE2, so a pattern the host's own engine would accept and RE2 would not is refused. - A duration is the signed 64-bit range of its total nanoseconds —
-9223372036.854775808s … 9223372036.854775807s, roughly ±292 years, which is cel-spec's own limit. It is checked wherever a duration is built: the conversion, duration+and-,timestamp - timestamp, and the duration operand oftimestamp ± duration. A host's own duration format may be wider —google.protobuf.Durationis ±10,000 years — and CEL's is a subrange of it, so a duration a transport can carry is not always one CEL can construct. - Eight string members are not CEL's, and each says so in its own declaration (
"spec": falsewith a reason):indexOf,lastIndexOfandsubstringindex by UTF-16 code unit rather than by code point,lowerAsciiandupperAsciiapply the host language's casing rather than touching the ASCII letters alone, andtrim,splitandjoindiffer from the strings extension's equivalents in the ways their reasons name.size,startsWith,endsWith,contains,matchesand concatenation are CEL's own. - An error is a VALUE that participates in short-circuit.
false && <missing key>isfalseandtrue || <missing key>istrue, from either side; an error that survives to the top of an evaluation becomes a thrownCelEvaluationErrorcarrying one of the codes inCEL_EVALUATION_CODES(no_such_key,numeric_overflow,division_by_zero,modulo_by_zero,index_out_of_range,invalid_regular_expression,invalid_conversion,optional_value_missing,async_value_unsupported, …) and the range of the text it is about. - Evaluation is synchronous, and a value that must be awaited is refused at every door it comes
through: an activation read, a registered implementation's result, a member read in any form, an
element entering a comprehension body, the value
cel.bindbinds, an optional's held value, and list membership. Each answersasync_value_unsupported, never a value passed along and never an answer decided about a value nothing touched: the element is refused where it is bound, and that refusal is terminal —xs.all(e, false)andxs.exists(e, true)over a list holding a promise refuse rather than answering off the one element that is readable, while an ordinaryno_such_keystill short-circuits as before. A container is deliberately not walked, sosize(xs)andxs + [3]carry it along and every way of reading the element out is refused. - A value says what it is by a string type key under
Symbol.for("telo.cel.value"), never by its constructor, so two copies of the engine agree about a value either of them built.null, a boolean, a string, anumber(double), abigint(int), aUint8Array(bytes), an array (list) and a plain object (a string-keyed map) carry no key; a uint, a timestamp, a duration, a type value, an optional, a map with typed keys and an error do. A plain object carrying a string-keyed look-alike brand is data, as it must be. - Every member read goes through one lookup over the value's own entries —
a.b,a['b'],a[expr],.?,[?]and a macro's field read alike — so no key, however it was computed, reaches a prototype, a method or a function property. A CEL map is built prototype-free, so__proto__,constructorandprototyperound-trip as data. - No
eval, nonew Function, no disk.compilebuilds a tree of closures over the runtime library, which is where every operator lives exactly once. In-process caches — compiled expressions per environment, compiled patterns, a call site's resolved overloads — are bounded, each with a declared capacity. evaluateToValueanswers the error value instead of throwing, for a caller that carries errors itself. A namespaced call is dispatched throughnamespaceFunction, supplied per evaluation; one nothing binds isunbound_function.
Emitting JavaScript
The same expressions compile to JavaScript source, as one module for a set of them:
const module = environment.emit(["a + b * 2", "xs.map(e, e * 2)"]);
module.key; // 64 hexadecimal characters: what a host names its stored copy by
module.text; // the module's source, which imports nothing
module.header; // { format, engine, environment, key, body } — the header line's own fields
// A host loads it however it loads a module, then:
const programs = programsFromEmittedModule(loaded, module, environment.emitterRuntime());
programs[0].evaluate({ a: 3n, b: 4n }); // 11n- Both backends answer identically, case for case — the same value, or the same error code and the same range. That is a property of the wiring rather than of the tests: every operator, comprehension, member read, name read, bool operand and call-site dispatch is one function both backends call, so the only thing the two compile differently is how control gets from one call to the next.
- The runtime is the factory's argument, never an import. The module's default export takes the
runtime support library and answers one synchronous function per expression, and the text names no
specifier — so it loads from a
data:URL, from a cache directory mounted anywhere, and under a host whose resolver is not Node's. A module importing a package would also accept a runtime of another version silently, which the key could not see. - The key covers the environment, not just the source. It hashes the emitter's format generation, the engine version, the environment's digest and the ordered list of expression sources. The digest is over the environment's resolved listing — every surviving function signature, every named type, every variable, every namespace, every option — so two environments built by different registration orders are one key, and a host that replaced or removed a standard function is a different one.
- The header is verified before anything runs, and it is five fields rather than three.
format,engineandenvironmentare provenance, and they are byte-identical for every module one engine writes against one environment — sokey(the module's own identity) andbody(the digest of everything after the header line) are what catch a store that answered the wrong lookup, a text truncated after its header, and a text edited after it was written.bodycovers theintegrityexport and so cannot live in it: the header line carries all five, the export carries the four a loaded object can be held to. Every mismatch a stored text shows is a recompile naming itself underrefused; a loaded module that declares nothing, declares another key or answers the wrong number of functions is refused withCelEngineError(emitted_module_rejected). - One store seam, and no loader.
environment.emittedModule(sources, store)reads and writes module text by key through an interface the host supplies; the engine touches no filesystem, and a hit parses nothing. A store's write should be atomic — written elsewhere, then renamed — because several hosts share one cache root; that is how a half-written entry is avoided, and thebodydigest is how one is detected if it is not. - Emission is deterministic: the same expressions in the same order against the same environment are the same bytes.
- It is faster, not differently behaved — and by exactly one thing: what the closure calls cost. Both backends pay the same shared runtime underneath, so an expression whose cost is a dotted-chain search and a conversion is a wash, while one that calls many small operations gains.
What it deliberately does not do
- Nothing is expanded at read time.
has(x),xs.map(i, i)andcel.bind(x, 1, x)are ordinary call nodes in the tree; the checker lowers them, so the source stays writable from the tree. - It knows no host's vocabulary. No type name, no annotation, no manifest word: a host registers
its own through
registerTypeand one schema resolver. The function catalog above is a set of functions over CEL's own types — no signature in it names a host type — which is what a dialect is. - It never type-checks on its way to evaluating. The checker decides every verdict and a consumer asks for them; compiling refuses only what cannot be compiled at all — a source that did not read whole.
- The LANGUAGE reaches no host facility. No filesystem, no clock, no network, no Node built-in — it runs unchanged in a browser, and so does the catalog: its clock and UUID functions read facilities every host has, and its nine host-backed functions reach nothing at all by themselves.
Reserved words
CEL reserves 21 words, and none of them can be read as a name: as break const continue else false for
function if import in let loop namespace null package return true var void while. Seventeen are refused
outright; true, false and null are literals where they stand, and in is the membership operator,
which is why a name position can never reach them.
A reserved word is still a legal member name — {'let': 1}.let, a.while(), a.in, a.true — which
is CEL's own rule: a member names an entry of a value, not a name in the expression's scope.
Nothing is reserved on a host's behalf: __proto__, prototype and constructor are ordinary names.
Keeping a host's own properties out of a CEL value is the member read's job, not a word list's — a
member key can be computed (a[request.query.k]), which no list of names would judge.
cel and optional are ordinary identifiers, but can never be registered as a namespace: the
standard macros are written on them.
