An idiomatic C# / .NET 10 implementation of the Common Expression Language.
CEL is Google's "safe expression" language for policy, validation, and rule engines: small, sandboxed, totally evaluated, and stable across implementations. This port targets full conformance with the spec and treats POCOs as first-class — protobuf is optional.
Declare what variables an expression can reference, compile, and evaluate:
using DotnetCel;
using DotnetCel.Types;
var env = CelEnv.NewBuilder()
.Variable("name", CelTypes.String)
.Build();
var program = CelExpression.Compile("'hello, ' + name", env);
var greeting = (string)program.Eval(new Dictionary<string, object?>
{
["name"] = "world",
})!;
// "hello, world"Plain CLR objects bind directly — no schema, no codegen, no protobuf. Top-level properties of an anonymous root become CEL variables; nested field access is resolved at runtime by the reflection-backed POCO adapter:
using DotnetCel;
using DotnetCel.Extensions;
using DotnetCel.Types;
public sealed record User(string Name, int Age, string[] Roles);
var env = CelEnv.NewBuilder()
.Use(StringsExtension.Instance)
.Variable("user", CelTypes.Object("User"))
.Build();
var program = CelExpression.Compile(
"user.Name.startsWith('a') && user.Age >= 18 && 'admin' in user.Roles",
env);
bool allowed = (bool)program.Eval(new
{
user = new User("alice", 25, ["admin", "user"]),
})!;CelExpression.Compile is the slow step (~50–200 µs); the returned
CompiledProgram is thread-safe and meant to be reused across millions of
evaluations.
Map CLR PascalCase properties to CEL expressions in any case style. Per-member
overrides via [JsonPropertyName]; per-env defaults via UsePocoNaming(...):
using System.Text.Json.Serialization;
public sealed class Account
{
[JsonPropertyName("user_name")]
public string UserName { get; init; } = "";
public int Age { get; init; }
[JsonIgnore]
public string SessionToken { get; init; } = "";
}
var env = CelEnv.NewBuilder()
.UsePocoNaming(PocoNamingConvention.SnakeCase)
.Variable("acc", CelTypes.Object("Account"))
.Build();
// CEL field names: user_name (from attribute), age (from convention).
// session_token is hidden by [JsonIgnore].
var program = CelExpression.Compile(
"acc.user_name == 'alice' && acc.age >= 18",
env);map, filter, all, exists, exists_one work over lists and map keys:
var program = CelExpression.Compile(
"items.filter(i, i.in_stock && i.price < 100).size() > 0",
env);
bool hasAffordable = (bool)program.Eval(new
{
items = new[]
{
new { in_stock = true, price = 30 },
new { in_stock = false, price = 50 },
new { in_stock = true, price = 200 },
}
})!;Extend the language with your own functions via ICelExtension:
using DotnetCel;
using DotnetCel.Types;
using DotnetCel.Values;
public sealed class GreetExtension : ICelExtension
{
public static readonly GreetExtension Instance = new();
private GreetExtension() { }
public void ConfigureEnv(CelEnv.Builder b) =>
b.Function("greet",
new OverloadDecl("greet_string", [CelTypes.String], CelTypes.String));
public void ConfigureRuntime(Action<string, OverloadFn> bind) =>
bind("greet_string",
args => CelValue.Of($"hello, {((StringValue)args[0]).Value}"));
}
var env = CelEnv.NewBuilder().Use(GreetExtension.Instance).Build();
CelExpression.Compile("greet('alice')", env).Eval(new Dictionary<string, object?>());
// "hello, alice"Full docs live at https://suhdev.github.io/csharp-cel/ (Astro Starlight,
sources under docs/) — getting-started, concepts, guides, and a
complete API reference. Run locally with cd docs && npm install && npm run dev.
src/DotnetCel.Core— AST, type system, value model, diagnostics. No external deps.src/DotnetCel.Parser— lexer + Pratt parser, macro expansion.src/DotnetCel.Checker— type checker, declarations, overload resolution.src/DotnetCel.Runtime— tree-walking evaluator, activations, POCO adapter, stdlib.src/DotnetCel.Extensions— strings, math, encoders, sets, optionals, bindings, network, block.src/DotnetCel— public façade (CelExpression,CompiledProgram).tests/DotnetCel.UnitTests— unit tests (181 cases).tests/DotnetCel.Conformance— runs the cel-spec textproto conformance corpus.docs/— Astro Starlight documentation site.
Run the harness against a checkout of cel-spec (sibling repo by default):
dotnet run --project tests/DotnetCel.Conformance
# or with a custom path:
dotnet run --project tests/DotnetCel.Conformance -- /path/to/cel-spec/tests/simple/testdata
# or specific files:
dotnet run --project tests/DotnetCel.Conformance -- ../cel-spec/tests/simple/testdata --only basic comparisons| Category | Status |
|---|---|
basic, bindings_ext, comparisons, enums, fp_math, integer_math, lists, logic, macros, network_ext, parse, plumbing, string |
100% |
block_ext, conversions, dynamic, fields, macros2, math_ext, proto3, string_ext, timestamps, wrappers |
85–98% |
encoders_ext, namespace, optionals, proto2 |
73–89% |
proto2_ext, type_deduction, unknowns |
0% — feature gaps |
| Total | 2082 / 2257 ran (92%) over 2454 cases (197 skipped) |
unknowns— partial-evaluation infrastructure. The runtime represents unknowns internally (UnknownValue); public API for emitting them from activations is pending.type_deduction—typed_resultmatcher in conformance harness not yet wired up;CompiledProgram.ResultTypeexists but the spec corpus doesn't drive it.proto2_ext— proto2 message extensions (theextendblock) — parser + provider lookup not yet implemented.- Reflection POCO adapter — annotated
[RequiresUnreferencedCode]; SourceGen variant pending for AOT/trim.