The CEL Language¶
A working reference for CEL as Celly implements it. The authoritative definition is cel-spec langdef.md; Celly tracks it exactly (verified by conformance).
Types¶
CEL has a small, fixed type universe:
| Type | Literals | Notes |
|---|---|---|
int |
42, -7, 0x1F |
64-bit signed; arithmetic overflow is an error, not wraparound |
uint |
42u, 0xFFu |
64-bit unsigned; the u suffix is required |
double |
1.5, .5, 1e3, -0.0 |
IEEE-754; 1.0/0.0 is +Inf, 0.0/0.0 is NaN (no error) |
bool |
true, false |
|
string |
"hi", 'hi', r"raw\n", """multi""" |
Unicode; size() counts code points, not UTF-16 units |
bytes |
b"abc", b"\xff" |
octet sequences |
list |
[1, 2, 3] |
heterogeneous allowed (typed list(dyn) then) |
map |
{"k": 1, 2: true} |
keys: int/uint/string/bool; 1 and 1u are the same key |
null_type |
null |
|
type |
int, string, … |
types are first-class: type(1) == int |
| timestamp/duration | timestamp('2024-01-01T00:00:00Z'), duration('90s') |
nanosecond precision |
Numeric behavior worth knowing¶
1 + 1.0 // ERROR: no such overload — arithmetic is NOT cross-type
1 < 1.5 // true — comparisons and equality ARE cross-type
1 == 1.0 // true — one number line for int/uint/double
9223372036854775807 + 1 // ERROR: return error for overflow (checked arithmetic)
-17 / 5 // -3 — integer division truncates toward zero
1u - 2u // ERROR: unsigned underflow
Operators (highest to lowest precedence)¶
() . [] member access, indexing, calls
! - unary
* / %
+ -
< <= > >= == != in
&&
||
?: ternary (right-associative)
in tests membership: 2 in [1, 2], 'k' in {'k': 1} (map keys).
Field selection and presence¶
msg.field // map key lookup or protobuf field access
has(msg.field) // presence test: map key exists / proto field set
msg.`weird-name` // backtick-escaped identifiers for non-identifier keys
Selecting a missing map key is an error; has() is the safe test. (Or use
optionals: msg.?field.orValue(default).)
Macros (comprehensions)¶
CEL has no loops. Instead, seven macros expand into bounded comprehensions:
has(m.f) // presence
list.all(x, x > 0) // every element satisfies
list.exists(x, x == target) // any element satisfies
list.exists_one(x, p(x)) // exactly one
list.map(x, x * 2) // transform
list.map(x, x > 0, x * 2) // filter + transform
list.filter(x, x > 0) // keep matching
Maps iterate over their keys: {'a': 1}.all(k, k != '').
Quantifiers are error-absorbing like &&/||: [1, 0].exists(x, 1/x > 0) is true
even though one element errors — see Errors & Absorption.
Name resolution and containers¶
An environment can have a container (namespace), e.g. mycompany.rules. A reference x
then resolves by trying mycompany.rules.x, mycompany.x, x — longest first. A leading dot
forces absolute resolution: .x only ever means top-level x.
Comprehension variables shadow everything: in list.exists(y, y.z == 0), y is always the
iteration variable even if a variable literally named "y.z" is bound.
Timestamps and durations¶
timestamp('2009-02-13T23:31:30Z').getFullYear() // 2009
timestamp('2009-02-13T23:31:30Z').getHours('-08:00') // 15 (timezone-aware)
timestamp('2009-02-13T23:31:30Z') + duration('1h') // arithmetic
duration('1h30m').getMinutes() // 90 (whole-unit conversion)
Gotchas the spec mandates (Celly matches them exactly): getMonth() is 0-based,
getDate() is 1-based, getDayOfMonth() is 0-based, getDayOfWeek() has 0 = Sunday.
Conversions¶
Explicit, never implicit: int('42'), string(3.14), double('1e3'), uint(7),
bytes('abc'), bool('true'), timestamp(1234567890), duration('90s'), dyn(x), type(x).
Out-of-range conversions are errors: int(1e99), uint(-1).