SQL semantic types

Use SQL domains to constrain valid query composition without conflating database semantics with driver-decoded application values.

Qubu tracks four independent facts for a field or result expression:

Fact Question it answers Example
JavaScript type What value does the driver give the application? string
Write types What values may an insert or update accept? string, Date, or both
Nullability Can the selected value be null? false
SQL domain Which portable SQL operations may consume the expression? SqlText or SqlUuid

The axes are deliberately separate. Both text() and uuid() decode to a JavaScript string, but their SQL behavior differs. Likewise, two timestamp() definitions may share SqlTimestamp while a custom column uses different JavaScript output and write types for its driver.

Built-in domains and capabilities

Schema helpers attach these portable semantic domains:

Helpers SQL domain Portable capabilities
text() SqlText text, equality, ordering
uuid() SqlUuid equality
integer() SqlInteger numeric, equality, numeric ordering
numeric() SqlDecimal numeric, equality, numeric ordering
boolean() SqlBoolean equality
date() SqlDate equality, date ordering
timestamp() SqlTimestamp equality, timestamp ordering
json<T>() SqlJson<T> no portable comparison capability
bigint() SqlBigInt numeric, equality, numeric ordering
binary() SqlBinary equality

Capabilities describe portable operation families. SqlTextLike is accepted by text functions and pattern matching, SqlNumericLike by arithmetic and numeric aggregates, and SqlOrderable<Group> by ordering operations. Equality and ordering groups allow related domains to interoperate: SqlInteger and SqlDecimal share the numeric group, for example.

Specific result domains are still retained. lower(textColumn) produces SqlText, while avg(integerColumn) produces SqlDecimal:

import { avg, integer, lower, table, text } from "qubu"
import type { SqlTypeOf } from "qubu"

const metrics = table("metrics", {
  label: text(),
  sampleCount: integer(),
})

const normalized = lower(metrics.label)
type LowerDomain = SqlTypeOf<typeof normalized>
// SqlText

const mean = avg(metrics.sampleCount)
type MeanDomain = SqlTypeOf<typeof mean>
// SqlDecimal

SqlTypeOf<T> reads the domain of any result-bearing expression. Named query projections retain a map of domains through QuerySqlTypeMap<T>; aliases, CTEs, LATERAL sources, scalar subqueries, set operations, and mutation RETURNING projections carry those domains forward.

UUID is not text-like

A UUID may be decoded as a JavaScript string without acquiring SQL text semantics. Contextual JavaScript literals remain ergonomic for compatible operators:

import { asc, eq, inList, like, lower, orderBy, table, uuid } from "qubu"

const records = table("records", { id: uuid() })

eq(records.id, "108cb836-20d2-41b2-8c23-f0c94700aa7e") // valid
inList(records.id, ["first-id", "second-id"]) // valid

lower(records.id) // TypeScript error: SqlUuid is not SqlTextLike
like(records.id, "%uuid%") // TypeScript error: SqlUuid is not SqlTextLike
orderBy(asc(records.id)) // TypeScript error: SqlUuid is not portably orderable

Plain JavaScript operands take their domain from the typed expression in that operation. This contextual typing does not relabel an expression: comparing a SqlUuid expression with a SqlText expression is still rejected. Cast when the database operation intentionally changes domains:

Scalar text functions bind primitive operands automatically, and coalesce() uses its first expression to type primitive fallbacks:

upper("Ada") // UPPER(?)
coalesce(metrics.label, "Anonymous") // COALESCE("metrics"."label", ?)
import { cast, like, text } from "qubu"

const idAsText = cast(records.id, text())
like(idAsText, "108c%")

Built-in definitions carry logical cast targets, so the active dialect can render TEXT, MySQL CHAR, or another configured spelling while the result remains string/SqlText. Use a custom definition or typedCast() when the target is vendor-specific.

Known incompatibility is rejected

Qubu checks capabilities and compatibility when it knows both SQL domains. Arithmetic and SUM/AVG require numeric-like expressions; text functions, concatenation, LIKE, and PostgreSQL ILIKE require text-like expressions; ordering and range comparisons require compatible ordering groups; and equality, IN, CASE, COALESCE, and set-operation fields require compatible equality groups. Boolean clauses require a boolean SQL domain.

These checks model portable capability and group relationships, not every database's implicit casts. An expression accepted by one database after an implicit conversion may therefore need an explicit typed cast in portable Qubu code.

Unknown domains preserve compatibility

column(), value(), call(), and untyped custom expressions default to SqlUnknown. Unknown is intentionally permissive, so existing extensions keep composing while authors adopt semantic types incrementally:

const legacyId = column<string>()
// ColumnSqlType<typeof legacyId> is SqlUnknown

SqlUnknown is an escape hatch, not evidence about the database. Prefer a declared domain for reusable extensions so incompatible composition fails at the call site. See Typed expressions for custom domains, functions, values, casts, and raw expressions.

Static metadata is not database proof

SQL semantic domains affect TypeScript only. A definition used explicitly as a cast target also contributes a logical or named runtime target. Those domains do not inspect the database, prove a migration safe, select a wire encoding, or verify that the rendered type name exists. The application remains responsible for keeping table definitions aligned with the database, and the driver adapter remains responsible for encoding parameters and decoding rows.