Documentation menu

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.

typesql
select
  id,
  amount satisfies integer!,
  metadata::json<{plan: string}> ->> 'plan' as plan
from titan.stripe.charges

The 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:

typesql
select
  round(sum(amount) / 100.0, 2) satisfies decimal(38,2) as gross_volume
from titan.stripe.charges
where not _deleted

If 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:

typesql
select email satisfies string!
from titan.stripe.customers
where email is not null

Remove 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:

typesql
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 > 250000

The 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:

typesql
select
  metadata::json<{plan: string, seats: integer}> as meta,
  meta ->> 'plan' as plan
from titan.stripe.charges

A 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_at are typed.