# Type rules

> What the analyzer checks, and the exact rule behind every type error it can produce.

Source: https://www.sendlang.com/docs/sendql/types
Section: SendQL

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.

```sendql
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.

**Rejected — the parser refuses this:**

```sendql
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.

**Rejected — the parser refuses this:**

```sendql
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.

**Rejected — the parser refuses this:**

```sendql
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:

**Rejected — the parser refuses this:**

```sendql
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.

**Rejected — the parser refuses this:**

```sendql
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:

**Rejected — the parser refuses this:**

```sendql
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 |

**Rejected — the parser refuses this:**

```sendql
count(open within 30d) >= "3"
```

**Aggregate fields** must exist on the source *and* be numeric:

**Rejected — the parser refuses this:**

```sendql
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:

**Rejected — the parser refuses this:**

```sendql
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:

**Rejected — the parser refuses this:**

```sendql
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](/docs/reference/reserved-words) 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.

**Rejected — the parser refuses this:**

```sendql
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.
