Type annotations#
Typesql is SQL with optional types. You annotate the places where you're depending
on an assumption — this column is never null, this number fits decimal(28,2),
this json has a plan field — and the checker verifies the assumption against
your lake's actual schema, before the query runs and every time the schema
changes underneath it.
select
id,
amount satisfies integer!,
metadata::json<{plan: string}> ->> 'plan' as plan
from titan.stripe.chargesThe guarantee#
Annotations never change what a query returns. Strip every annotation from a
typesql document and you have plain SQL that runs identically — same rows, same
values, same everything. This is a structural guarantee, not a convention: types
are checked and then erased; no execution path can even read them.
So there is no migration decision to make. Annotate one expression in one query
and stop there, or type a whole model — every point between "plain SQL" and
"fully typed" is a working program.
satisfies: check an expression#
Write expr satisfies type anywhere an expression is legal:
select
round(sum(amount) / 100.0, 2) satisfies decimal(38,2) as gross_volume
from titan.stripe.charges
where not _deletedIf a schema change ever makes that expression something other than a decimal, the
model fails its check with a diagnostic pointing at this exact span — instead of a
dashboard quietly rendering garbage. Every diagnostic carries a stable TS code,
the location, and a help: line with the fix.
satisfies is not a cast. amount::string converts a value; `amount satisfies
string asserts a fact, and fails the check if the fact is false. Use ::` to
change data, satisfies to catch drift.
Non-null: ! and narrowing#
string! means "a string, and never null". The checker understands how your query
logic affects nullability — this passes:
select email satisfies string!
from titan.stripe.customers
where email is not nullRemove the where clause and it fails: email is nullable in the schema, and
nothing in the query has ruled the nulls out. The reverse also holds — a column
that arrives non-null becomes nullable on the outer side of a left join, and the
checker tracks that too. The check reflects what the query does, not just what the
schema says.
Typed CTE headers#
A CTE header can declare its columns' types — turning the boundary between query
stages into a checked contract:
with monthly(month: date!, gross_volume: decimal(38,2)) as (
select
date_trunc('month', created_at)::date,
round(sum(amount) / 100.0, 2)
from titan.stripe.charges
where not _deleted
group by 1
)
select * from monthly where gross_volume > 250000The body is checked against the header, and everything downstream of monthly
gets the declared types. When it's stripped, the header loses its types —with monthly(month, gross_volume) as (…) — and runs as ordinary SQL.
Typed json#
Give a json column a shape, and accesses into it stop being guesses:
select
metadata::json<{plan: string, seats: integer}> as meta,
meta ->> 'plan' as plan
from titan.stripe.chargesA typo like meta ->> 'paln' is now a check failure, not a null column
discovered three dashboards later.
In the editor#
The query editor runs the same checker as it types. Hover any expression for its
inferred type; inlay hints show the types the checker already knows, and clicking
one writes it into the query as a real satisfies annotation — gradual typing,
one click at a time. Quick fixes carry the same edits the diagnostics suggest.
Where to go next#
Writing models — models are checked against your
lake on every run, and a-- lint:header tunes which diagnostics block.The type system pages cover the full grammar: scalars,
decimal(p,s), list and
struct types, table types, and how system columns like_synced_atare typed.