SendQL
Type rules
What the analyzer checks, and the exact rule behind every type error it can produce.
Parsing and type-checking are separate stages. A query that parses is well-formed; a query that analyzes is meaningful. This page is the complete set of rules the second stage enforces.
The five types
string, number, bool, datetime, duration.
duration never appears as an attribute’s type — it only exists as a literal, in
a window (within 30d), an age comparison (> 7d), or a workflow schedule. So
in practice an attribute is one of the other four.
An enum is not a sixth type. It is a string attribute with an additional
constraint on which values are allowed.
Operators
Ordering — <, <=, >, >=. Both operands must be orderable and must
have the same type. Orderable means number, datetime, duration — and
string, which compares lexicographically.
attr.plan > "m"
That is legal, and does what it says: plans whose name sorts after "m". It is
almost never what anyone means, but the language is not in the business of
guessing.
attr.plan > 5
operator > is not valid between string and number
Equality — =, !=. Both operands must have the same type. There is no
coercion whatsoever: attr.score = "10" is an error, not a silent parse of the
string.
String matching — contains, starts with, ends with. The left operand
must be a string. The right operand is a string literal by grammar, so it
cannot be anything else.
attr.score contains "1"
Sets — in [...]. Every element must have the same type as the left operand.
A mixed list is an error, reported per offending element.
attr.score in [1, "two", 3]
Presence — has, is known, is unknown. No type rule at all. They work on
every type, and are the only operators that do.
Enum membership
When an attribute is constrained to a set of allowed values, =, != and
in [...] additionally check that the literal is a member:
attr.subscription in ["free", "gold"]
"gold" is not an allowed value for attr.subscription; allowed values are "free", "pro", "enterprise"
The check is deliberately not applied to ordering or string-matching
operators, where testing against a non-member is legitimate (attr.subscription starts with "p").
Age terms
now - attr.x requires x to be a datetime. The result is a duration, so the
comparison must be against a duration.
now - attr.plan > 7d
age expression `now - attr.plan` requires a datetime attribute, got string
A between whose bounds are both known and reversed is rejected:
now - attr.signup_date between 14d and 3d
Event terms
| Term | Compares against |
|---|---|
count(...) | a number |
sum, avg, min, max | a number |
last(...), first(...) | a time — now, now - <duration>, or a date |
count(open within 30d) >= "3"
Aggregate fields must exist on the source and be numeric:
sum(template of open) > 1
aggregate sum requires a numeric field, but open.template is string
where properties must exist on the source, and their comparisons are
type-checked against the property’s own type:
exists(open where colour = "red")
Activity properties must be promoted before they can be filtered or aggregated, and the promoted type is then checked exactly as an event property’s would be.
Windows
A between window whose bounds are both bare date literals is checked for
reversal:
count(open between 2026-02-01 and 2026-01-01) > 0
A window with now or an offset in it is not statically checked — its bounds are
not known until it runs.
Reserved words
A reserved word may not be used as a bare
name: not as an event name, not as a where property name, not as an aggregate
field name.
sum(count of activity.order) > 1
Qualified and quoted names are exempt, because they cannot be ambiguous.
attr.count is fine; so is in list "count".
Unknowns short-circuit
If one side of a comparison has already failed to resolve — an unknown attribute, an unknown property — the analyzer does not then also complain that its type does not match. You get one error about the real problem, not two errors about the same typo.