questdb-typesafe-client

Schema Definition

Define type-safe table schemas with column types, partitioning, WAL, and more.

Defining a Table

Use defineTable with the q column builder to define typed table schemas:

import { defineTable, q } from "@fcannizzaro/questdb-typesafe-client";

const sensors = defineTable({
  name: "sensors",
  columns: {
    ts: q.timestamp.designated(),
    device_id: q.symbol(),
    temperature: q.double(),
    humidity: q.float(),
    label: q.varchar(),
    active: q.boolean(),
  },
  partitionBy: "DAY",
  wal: true,
});

Defining a Table from a Zod Schema

As an alternative to columns, you can pass a Zod v4 object schema directly. Use .meta() annotations on individual fields for QuestDB-specific type overrides:

import { defineTable } from "@fcannizzaro/questdb-typesafe-client";
import { z } from "zod/v4";

const energyReadings = defineTable({
  name: "energy_readings",
  schema: z.object({
    ts: z.date().meta({ designated: true }),
    source: z.string().meta({ symbol: true }),
    reading: z.string().meta({ symbol: true }),
    power_kw: z.number(),
    energy_kwh: z.number(),
    meter_active: z.boolean(),
  }),
  partitionBy: "DAY",
  wal: true,
});

Default Type Mapping

Zod types are automatically mapped to QuestDB column types:

Zod TypeDefault QDB TypeMeta Override
z.boolean()BOOLEAN
z.number()DOUBLE.meta({ int: true }) for INT, .meta({ float: true }) for FLOAT
z.string()VARCHAR.meta({ symbol: true }) for SYMBOL
z.date()TIMESTAMP.meta({ designated: true }) for designated timestamp
z.bigint()LONG

Wrapper types like .optional() and .nullable() are automatically unwrapped to determine the base QuestDB type.

.meta() Annotations

Use the QuestDBColumnMeta fields in .meta() calls:

import type { QuestDBColumnMeta } from "@fcannizzaro/questdb-typesafe-client";

z.date().meta({ designated: true })   // designated timestamp (required for partitioned tables)
z.string().meta({ symbol: true })     // SYMBOL instead of VARCHAR
z.number().meta({ int: true })        // INT instead of DOUBLE
z.number().meta({ float: true })      // FLOAT instead of DOUBLE

When using schema, the designated timestamp compile-time check is not enforced — .meta() annotations are invisible to the TypeScript type system. The runtime conversion handles them correctly. Use the columns approach with q.timestamp.designated() if you need compile-time designated timestamp validation.

Table Options

OptionTypeDefaultDescription
namestringrequiredTable name
columnsobjectColumn definitions using q.* builders
schemaz.ZodObjectZod v4 object schema (alternative to columns)
partitionByPartitionBy"DAY"NONE, HOUR, DAY, WEEK, MONTH, YEAR
walbooleantrueEnable Write-Ahead Log
dedupKeysstring[]Deduplication upsert keys (requires WAL)
ttlstringPartition TTL (e.g. "90d")
maxUncommittedRowsnumberMax uncommitted rows before flush
o3MaxLagstringOut-of-order commit lag (e.g. "1s")

columns and schema are mutually exclusive — provide one or the other, not both.

Designated Timestamp

QuestDB requires a designated timestamp column for partitioned tables. The library enforces this at the type level — a compile error occurs if partitionBy is not "NONE" and no designated timestamp is defined.

// This compiles
const valid = defineTable({
  name: "t",
  columns: { ts: q.timestamp.designated(), v: q.double() },
  partitionBy: "DAY",
});

// This produces a type error
const invalid = defineTable({
  name: "t",
  columns: { v: q.double() },
  partitionBy: "DAY", // Error: partitioned table requires a designated timestamp
});

Deduplication

When using WAL mode, you can configure upsert deduplication keys:

const sensors = defineTable({
  name: "sensors",
  columns: {
    ts: q.timestamp.designated(),
    reading_id: q.uuid(),
    source: q.symbol(),
    power_kw: q.double(),
  },
  wal: true,
  dedupKeys: ["ts", "reading_id"],
});

Column Types

All 20+ QuestDB column types are supported:

BuilderQuestDB TypeTypeScript TypeNullable
q.boolean()BOOLEANbooleanNo
q.byte()BYTEnumberNo
q.short()SHORTnumberNo
q.char()CHARstringYes
q.int()INTnumberYes
q.float()FLOATnumberYes
q.long()LONGbigintYes
q.double()DOUBLEnumberYes
q.decimal()DECIMALstringYes
q.date()DATEDateYes
q.timestamp()TIMESTAMPDateYes
q.timestamp.designated()TIMESTAMPDateYes
q.timestamp.ns()TIMESTAMP (ns)bigintYes
q.symbol()SYMBOLstringYes
q.varchar()VARCHARstringYes
q.string()STRINGstringYes
q.uuid()UUIDstringYes
q.ipv4()IPV4stringYes
q.binary()BINARYUint8ArrayYes
q.long256()LONG256stringYes
q.geohash(bits)GEOHASH(Nb)stringYes
q.array(elementType)TYPE[]number[]Yes

boolean, byte, and short are the only non-nullable types in QuestDB — they default to false / 0 when NULL.

Symbol Options

Symbol columns support advanced options via a chainable builder:

const table = defineTable({
  name: "sensors",
  columns: {
    ts: q.timestamp.designated(),
    source: q.symbol.options()
      .capacity(256)    // dictionary capacity
      .cache()          // enable symbol cache
      .index(1024)      // create index with block capacity
      .build(),
    power_kw: q.double(),
  },
});

Available symbol options: capacity(n), cache(), nocache(), index(), index(blockCapacity).

Geohash

Geohash columns accept a precision in bits (1-60):

q.geohash(30) // 30-bit geohash

Array

Numeric array columns specify the element type:

q.array("double")  // DOUBLE[]
q.array("int")     // INT[]
q.array("float")   // FLOAT[]
q.array("long")    // LONG[]
q.array("short")   // SHORT[]

Nanosecond Timestamps

For nanosecond-precision timestamps (stored as bigint):

q.timestamp.ns()

Runtime Validation

Every column type includes a Zod v4 schema for insert-time validation. For example:

  • byte validates integers in the range -128 to 127
  • short validates integers in the range -32768 to 32767
  • char validates strings of max length 1
  • uuid validates UUID format
  • ipv4 validates IPv4 format

On this page