DuckDB

How Reylo types queries on DuckDB

Reylo lets you import .sql files into TypeScript, with every query typed from your live database. This page covers how it types and runs queries on DuckDB: column types, null values, row counts, parameters, files and drivers.

DuckDB versions2.0, through @duckdb/node-api
ConnectionA duckdb: URL in DATABASE_URL or direct_url, as duckdb:./app.duckdb; a relative path starts at your package.json, and duckdb::memory: is an empty database, for queries that only read files
Drivers@duckdb/node-api (Drivers)
Type checkingDuckDB runs inside your app, so the type check prepares each query on your project's own @duckdb/node-api, which you install. It opens the database file read-only for each query and closes it right after, and while your app has the file open, it reads a copy of it. On Windows no other process can read a file DuckDB has open for writing, so the type check needs your app stopped
View the schema behind the examples
create type mood as enum ('happy', 'sad');

create table teams (
  id integer primary key,
  name varchar not null
);

create table users (
  id integer primary key,
  name varchar not null,
  email varchar unique,
  team_id integer references teams (id),
  github_id bigint,
  mood mood,
  created_at timestamptz not null default now()
);
create table events (
  id bigint primary key,
  user_id integer not null references users (id),
  kind varchar not null,
  tags varchar[],
  meta struct(host varchar, pid integer),
  total decimal(12, 2),
  at_day date
);

Data types

DuckDB's prepared statement reports the exact type of every parameter and column, and .run() returns one value for each type:

DuckDB typeTypeScriptThe value .run() returns
BOOLEANbooleantrue
TINYINT, SMALLINT, INTEGER, UTINYINT, USMALLINT, UINTEGERnumber42
BIGINT, UBIGINT, HUGEINT, UHUGEINT, BIGNUMbigint9007199254740993n
FLOAT, DOUBLEnumber1.5
DECIMAL(p, s)string"12345678901234567.89"
VARCHAR, UUID, TIME, TIMETZ, BITstring"Ada"
ENUMits labels"happy"
BLOBUint8ArrayUint8Array.of(1, 2, 255)
DATEstring"2026-10-20"
TIMESTAMP, TIMESTAMP_S, TIMESTAMP_MS, TIMESTAMP_NSstring"2026-10-20T10:00:00.5"
TIMESTAMPTZstring"2026-10-20T08:00:00Z"
INTERVALstring"P1Y2M3DT4H5M6.789S"
JSONJson{ a: [1, "x"] }
T[] (a LIST) and T[n] (an ARRAY)(T | null)[]["a", "b"]
STRUCT(a T, …){ a: T | null; … }{ host: "h", pid: 7 }
A STRUCT without field names, as (1, 'a') or row(…) makes[T | null, …][1, "a"]
MAP(K, V){ key: K; value: V | null }[][{ key: "k", value: 1 }]
UNION(a T, …){ tag: "a"; value: T | null } | …{ tag: "str", value: "s" }
VARIANTunknownthe value it holds, in its own type's form: 42

Integers

A BIGINT or wider integer is a bigint, so no value is ever rounded. A parameter of one takes a number, a bigint or a string of digits.

Query
select id, github_id, count(*) over () as n
from users
Params
void
Returns
{ id: number; github_id: bigint | null; n: bigint }[]

Decimals

A DECIMAL is its exact text, as DuckDB stores it with its scale, and a parameter of one takes that text.

Dates and times

A TIMESTAMP is its wall-clock text with a T between the date and the time, and a TIMESTAMPTZ its instant in UTC, as Temporal.Instant.from reads it. A year past 9999 is written as ISO 8601 extends it, +010000-01-01, and an infinite date or timestamp as "infinity" or "-infinity". An INTERVAL is an ISO 8601 duration, which its parameter takes back.

Nested types

A STRUCT, a LIST, a MAP and a UNION hold their values as a row does, a BIGINT inside one a bigint too, and a STRUCT whose fields have no names is a tuple of its values in order. DuckDB lets any field and any element be NULL, so each is typed | null.

Query
select tags, meta, meta.host as host
from events
Params
void
Returns
{
  tags: (string | null)[] | null;
  meta: { host: string | null; pid: number | null } | null;
  host: string | null;
}[]

JSON

A JSON column is the parsed value, typed Json, the type Reylo exports for any JSON value. A parameter cast to json, or to a JSON Schema with ::json('…'), is any JSON value or the values its schema admits; where the query takes no null for it, null is JSON's null.

Files

A query can read Parquet, CSV and JSON files with read_parquet, read_csv and read_json. DuckDB reads a file's columns when it prepares the query, so the files, or a sample with the same columns, must exist wherever the type check runs, at the same paths. A relative path starts at your package.json in the type check, and at your app's working directory when it runs.

select level, count(*) as n
from read_parquet('logs/*.parquet')
group by all

Reylo keeps each query's analysis in .reylo/ until your database's schema changes, and a file's columns aren't part of the schema: after changing them, delete .reylo/ so that the next type check reads the files again. Every column of a file may be NULL, since nothing declares one NOT NULL.

Nullability

Every column is typed | null unless Reylo can tell it never is null. DuckDB's own description of a query doesn't say which columns hold NULL reliably, so Reylo reads your schema and the query:

NOT NULL columns

A table's column is non-null if it's declared NOT NULL, or is part of its primary key.

Query
select id, name, email
from users
Params
void
Returns
{ id: number; name: string; email: string | null }[]

Outer joins

An outer join makes the joined table's columns nullable, and a test in the where that fails on NULL shows the row exists.

Query
select u.name, t.name as team
from users u
left join teams t on t.id = u.team_id
Params
void
Returns
{ name: string; team: string | null }[]
Query
select u.name, t.name as team
from users u
left join teams t on t.id = u.team_id
where t.id is not null
Params
void
Returns
{ name: string; team: string }[]

Expressions

Arithmetic, comparisons, ||, coalesce and case are non-null over non-null values, and so are DuckDB's functions of one value such as lower and length. A division is nullable, since SET error_on_division_by_zero = false makes dividing by zero return NULL, and so is a part of a date, such as year(…), which is NULL for an infinite date.

Query
select upper(email) as shout,
  coalesce(email, name) as contact,
  length(name) as n,
  id / 2 as half
from users
Params
void
Returns
{
  shout: string | null;
  contact: string;
  n: bigint;
  half: number | null;
}[]

Aggregates

A count is never null, but count_if is null over no rows. Any other aggregate is null over no rows, and over a group's rows only where its argument is.

Query
select team_id, count(*) as members, max(name) as last
from users
group by team_id
Params
void
Returns
{ team_id: number | null; members: bigint; last: string }[]

Cardinality

Reylo infers how many rows a query returns, and types .run()'s result to match:

.run() returnscardinalityWhen the query
Rowoneis an aggregate without a group by, or with a group by all whose columns read the tables only inside aggregates; has no from; is values of one row; or inserts one row and has returning
Row | nullmaybeOnematches every column of a primary key or a unique constraint of its one table with =; has limit 1; inserts one row on conflict do nothing, or do update with a where, and has returning; or is an update or delete by key with returning
voidnoneis an insert, update or delete without returning, or another statement that returns no rows, such as create table
Row[]manyis anything else

Parameters

Placeholders

Write parameters as :name, and pass .run() an object with those names, without the colon. You can use DuckDB's $1, ? or ?1 instead, but not beside names; then .run() takes an object keyed by number, { 1: 42 }. DuckDB's own named form, $name, is an error that says to write :name.

What a parameter takes

A parameter accepts the same values .run() returns for its type, so you can pass a row straight into the next query. A BIGINT or wider integer also takes a number or a string of digits, and a STRUCT an object of its fields. A list takes no null element, so remove one before passing a list back in, and a UNION takes a member's value, not null.

A fraction for an integer, such as 1.5 for an INTEGER, makes .run() throw before the query runs, where DuckDB would round it to 2.

Parameters without a type

DuckDB types a parameter from where it stands: compared with a column, written to one, or cast. Anywhere else, such as a bare select :x or id + :n, DuckDB can't type the query at all, and Reylo shows an error at the parameter. Cast it:

Query
select :limit::integer + 1 as next
Params
{ limit: number }
Returns
{ next: number }

Parameters that may be null

A parameter accepts null where the query lets it be NULL. Such a parameter is optional in .run(), and leaving it out passes null.

Cast to nullable(…)

A cast to nullable(type) makes the parameter that type or null.

Query
select id, name
from users
where name = coalesce(:name::nullable(varchar), name)
Params
{ name?: string | null }
Returns
{ id: number; name: string }[]

Tested for NULL, or written to a nullable column

A parameter that the query tests for NULL or passes to coalesce, or writes directly into a column that accepts NULL, takes null.

Query
insert into users (id, name, email)
values (:id, :name, :email)
Params
{ id: number; name: string; email?: string | null }
Returns
void

Database errors

With .try(), an error DuckDB raised comes back as { error }:

FieldWhat it holds
codeThe kind of error, as "CONSTRAINT" for Constraint Error: Duplicate key …
messageDuckDB's message

Drivers

Reylo decodes its query results itself, so settings on your connection don't affect Reylo's queries.

@duckdb/node-api

Pass a connection. A transaction is the connection's own, around the queries you run on it:

import { DuckDBInstance } from "@duckdb/node-api";
import getUser from "./get_user.sql";

const instance = await DuckDBInstance.create("app.duckdb");
const db = await instance.connect();

const user = await getUser.run(db, { id: 1 });

Limits

What Reylo can't type on DuckDB, and what to do instead:

LimitInstead
A PIVOT without its values, whose columns are the values it readsName the values: pivot logs on level in ('info', 'warn') using count(*)
A query in syntax Reylo's parser doesn't read, such as PIVOT or SUMMARIZE: its columns are typed, but each may be NULL and the query returns Row[]None needed: the types hold, if loosely
An app that opens the database file for writing at the moment the type check reads it, which DuckDB refusesStart the app again
.runSync(), since @duckdb/node-api answers through a promiseUse .run()
DuckDB in the browser, through @duckdb/duckdb-wasm, which .run() doesn't take yetRun queries through @duckdb/node-api
rows(…) and row(…) parametersPass each value as a parameter of its own
A file of several statements, such as a migrationPut each statement in a file of its own
Released under the MIT License. Copyright © 2026 Colin McDonnell.

On this page

GitHub