Skip to content

Logic and conditions

CEL provides logical operators and conditional expressions that let you combine multiple checks into a single validation rule. These are essential for writing Protovalidate rules that go beyond simple, single-field constraints.

The && operator requires both conditions to be true. This is the most common way to combine checks in validation rules. CEL's && is essential for cross-field validation:

this.min_price >= 0.0 && this.min_price <= this.max_price
size(this.name) > 0 && !this.name.contains(this.secret)

The || operator requires at least one condition to be true. Use it to require that at least one of several fields is present or to provide alternatives:

has(this.email) || has(this.phone)
this.status == "active" || this.override_enabled

The ! operator negates a boolean expression.

!this.contains("password")
!(this.startsWith("test_") && this.size() < 10)

Parentheses control evaluation order, just like in most programming languages. Without them, && binds more tightly than ||.

(this.startsWith("prod-") || this.startsWith("staging-")) && this.size() <= 63

CEL supports the ternary operator condition ? value_if_true : value_if_false for conditional logic. In Protovalidate, this is especially useful when a CEL expression returns a string instead of a bool: an empty string means the rule passes, and a non-empty string becomes the validation error message. This lets you produce dynamic error messages that include runtime values.

this.amount <= this.balance ? "" : "insufficient balance"

Here's how that looks in a Protovalidate rule. The ternary produces a dynamic error message with actual field values — something a static message string can't do:

import "buf/validate/validate.proto";
message TransferRequest {
uint64 amount = 1;
uint64 balance = 2;
option (buf.validate.message).cel = {
id: "transfer.sufficient_balance"
// The expression returns a string: empty for success, non-empty for failure.
// The returned string becomes the violation message.
expression:
"this.amount <= this.balance ? ''"
": 'cannot transfer ' + string(this.amount)"
" + ' with a balance of ' + string(this.balance)"
};
}

CEL evaluates && and || left to right and stops as soon as the result is determined. With &&, if the left side is false, the right side is never evaluated. With ||, if the left side is true, the right side is skipped. This isn't just an optimization — it lets you write guard conditions that prevent runtime errors.

Check that a list has elements before accessing the first one:

size(this) > 0 && this[0] == "primary"

Without the guard, accessing this[0] on an empty list would cause an index-out-of-bounds error.

Check for zero before dividing:

this.denominator != 0 && this.numerator / this.denominator < 100

For more on runtime errors like these, see common errors.

A common Protovalidate pattern for optional fields is !has(this.field) || condition. This reads as "if the field is set, it must satisfy the condition." If the field isn't set, the rule passes without evaluating the condition.

!has(this.nickname) || this.nickname.size() >= 2

Here's a complete example using this pattern in a Protovalidate rule:

import "buf/validate/validate.proto";
message UserProfile {
// name is always required.
string name = 1 [(buf.validate.field).string.min_len = 1];
// nickname is optional, but if provided it must be between
// 2 and 30 characters.
optional string nickname = 2;
// The message-level rule validates nickname only when it's present.
option (buf.validate.message).cel = {
id: "nickname.length"
message: "nickname must be between 2 and 30 characters"
expression: "!has(this.nickname) || (this.nickname.size() >= 2 && this.nickname.size() <= 30)"
};
}

Real-world validation often combines several logical operators, field presence checks, and conditional expressions into a single rule. Here's an example that validates a shipping request with multiple interdependent constraints:

import "buf/validate/validate.proto";
message ShippingRequest {
option (buf.validate.message).cel = {
id: "shipping.express_weight_limit"
// When the expression returns a string, an empty string means
// validation passed and a non-empty string is the error message.
expression:
"!has(this.express) || !this.express ? ''"
": this.weight_kg <= 30.0 ? ''"
": 'express shipping is limited to 30 kg, but package weighs '"
" + string(this.weight_kg) + ' kg'"
};
string destination = 1 [(buf.validate.field).string.min_len = 1];
double weight_kg = 2 [(buf.validate.field).double.gt = 0.0];
bool express = 3;
}