Skip to content

Protobuf Support

CEL's type system is defined in terms of protobuf. The Celly.Protobuf package plugs protobuf messages into the core through the provider seam (ITypeProvider/ITypeAdapter).

Protobuf support is a separate step from extension libraries

Enabling extension libraries (Libraries = [StringsLibrary.Instance, …]) and enabling protobuf type support (TypeProvider/Adapter) are two independent settings. Adding the extension libraries does not turn on protobuf, and vice-versa. If you construct messages (MyMessage{...}), read message fields, or use wrappers / enums / Any, you must set TypeProvider and Adapter — otherwise every one of those evaluations fails with "unknown type". (Enabling extensions but not protobuf is the single most common misconfiguration; it makes all proto tests fail while everything else works.)

Setup

Two lines turn it on — a registry built from your message descriptors, wired into both TypeProvider (construction, field types, enums) and Adapter (adapting IMessage values you pass in):

using Celly;
using Celly.Protobuf;

// 1. Build a registry from your proto message types. One descriptor pulls in its whole
//    dependency graph — nested types, enums, extensions — recursively. WKTs are always on.
var registry = ProtoTypeRegistry.FromFiles(
    MyMessage.Descriptor.File,
    AnotherMessage.Descriptor.File);

// 2. Wire it into BOTH TypeProvider and Adapter.
var env = CelEnv.Create(new CelEnvSettings
{
    Container = "my.pkg",          // so expressions can say MyMessage{...} unqualified
    TypeProvider = registry,       // message construction, field types, enum constants
    Adapter = registry,            // adapts IMessage values in activations

    // Extensions are separate — add them here if you also want strings/math/optionals/etc.:
    // Libraries = [StringsLibrary.Instance, MathLibrary.Instance, OptionalsLibrary.Instance],
});

That's it — MyMessage{...} construction, msg.field access, has(msg.field), wrappers, Any, and enum constants now all work. To enable everything (all extension libraries and protobuf) in one env, see the full configuration.

What you can do

// Construct messages (field types are checked at compile time when the checker runs)
MyMessage{name: 'x', count: 3}

// Read fields — unset message fields read as their default instance
msg.sub_message.leaf_value

// Presence
has(msg.name)              // proto3 scalar: non-default; proto2/message: set

// Enums are ints (legacy mode, the CEL default)
msg.status == my.pkg.Status.ACTIVE

Well-known type mappings

Well-known protobuf types collapse into CEL-native values — you never see the wrapper message itself:

Proto type CEL view
Int32ValueBytesValue (wrappers) the primitive, or null when unset
Timestamp / Duration CEL timestamp / duration
Struct map(string, dyn)
Value the dynamic value (null, double, string, bool, map, list)
ListValue list(dyn)
Any unpacked automatically via the registry; unresolvable Any compares bytewise
FieldMask / Empty JSON forms ("a,b" / {}) when converted through Value

Null-assignment rules mirror the spec: MyMessage{wrapper_field: null} leaves the field unset; null in a repeated/map slot of message-kind type is pruned; assigning null to a Struct- or ListValue-typed field is an error (their CEL types are map/list, which reject null).

Message equality

m1 == m2 is field-wise CEL equality: presence-aware, and NaN inside a message is unequal to itself (IEEE semantics) — deliberately not proto.Equal, which compares doubles bitwise. This is what the conformance suite requires.

Strong-enum mode

By default (and per the CEL spec) proto enums are just ints. Celly also offers strong enums — enums as distinct named types — which the conformance suite's strong_* sections document:

var registry = ProtoTypeRegistry.FromFiles(strongEnums: true, MyMessage.Descriptor.File);
var env = CelEnv.Create(new CelEnvSettings
{
    TypeProvider = registry,
    Adapter = registry,
    Libraries = [registry.CreateEnumConversionLibrary()],  // EnumName(int|string) conversions
});
// strong mode:
type(Status.ACTIVE) == my.pkg.Status     // enums have their own type
Status(1)                                 // int  -> enum conversion
Status('ACTIVE')                          // name -> enum conversion
int(Status.ACTIVE)                        // enum -> int (restored in strong mode)
msg.status == Status.ACTIVE               // field values are enum-typed

Choose one mode per environment; they are intentionally different semantics.

Extensions (proto2)

Extension fields are addressed by their full name — via the ProtosLibrary macros or backtick selectors:

proto.getExt(msg, my.pkg.my_extension)
has(msg.`my.pkg.my_extension`)