Optionals & null
CEL has two ways to represent “no value”: null and optional<T>
without a value. They’re different, and you should usually use the second
one for “this might not be there” semantics.
null is a regular value
null == null // truetype(null) // null_typeaccount.next_login == null // a normal field comparisonYou can declare a CEL variable as null_type (rare), or — much more
commonly — as a wrapper or message type that might be null at runtime.
.Variable("account", CelTypes.Object("Account"))// account may legitimately be null at runtimeWhen the binding is null:
account == null // truehas(account.id) // error: cannot get field of nullaccount.id // errornull propagates as an error through select. Use has(...) to absorb
the error gracefully.
optional<T> is the principled “maybe a value” type
The optional extension introduces a typed wrapper:
var env = CelEnv.NewBuilder() .Use(OptionalsExtension.Instance) .Variable("maybe_id", CelTypes.Optional(CelTypes.Int)) .Build();maybe_id.hasValue() // boolmaybe_id.value() // int (errors if empty)maybe_id.orValue(0) // int — fallback if emptyoptional.of(42) // optional<int> with a valueoptional.none() // empty optional<dyn>The runtime representation is OptionalValue:
public sealed record OptionalValue(CelValue? Inner) : CelValue{ public bool HasValue => Inner is not null;}The ?. and ?[k] operators
Optionals also enable null-safe attribute access:
account.?profile.?image_url.orValue('/default.png')x.?y returns:
optional.none()ifxis null or the field is missing,optional.of(x.y)otherwise.
The result is an optional<T> that you typically chain through more
?. selects and end with orValue(default).
Wrapper types unwrap to nullable primitives
When you read a google.protobuf.Int32Value field via the proto type
provider, an unset wrapper surfaces as null (not zero), and a set wrapper
surfaces as an unwrapped int. This is the distinction that makes CEL
has(msg.maybe_id) ? string(msg.maybe_id) : 'none' work as you’d expect.
If you want optional semantics without proto, use the optional extension directly.
optMap / optFlatMap
The optionals extension contributes two parser macros for chaining:
maybe_user.optMap(u, u.profile.image_url)// ⇒ has-checked map: returns optional<string>
maybe_user.optFlatMap(u, u.maybe_avatar)// ⇒ flatten when the body is itself optionalThese are equivalent to if hasValue then of(f(value)) else none() but
much more readable in chains.
Null-safe field selects on POCOs
The POCO adapter treats a missing field as a no_such_field error rather
than null. To get null-safe access on POCO data, either:
- use
has(obj.field) ? obj.field : default, - or use
obj.?field.orValue(default)if you’ve enabled the optionals extension (the parser desugars.?regardless of the receiver type).
Truthiness
CEL does not coerce to bool. if (s) (where s: string) is a type
error. Always be explicit:
size(s) > 0has(account.id)account.id != ''maybe_thing.hasValue()This is intentional — implicit truthiness is a frequent source of bugs in expression languages.
When to use which
| Situation | Use |
|---|---|
| The value is genuinely sometimes null at runtime (e.g. a proto wrapper) | null semantics — use has(...) to test |
| You’re modelling a “maybe” outcome explicitly | optional<T> |
| You’re chaining many “maybe” attribute reads | ?. + optional<T> + orValue |
| You want an attribute to silently default to a value | obj.?field.orValue(default) |
See also
- Errors & unknowns — when null propagates as an error.
- Optionals extension reference.