DB Schema Types
@forinda/kickjs-db schemas drive both runtime SQL and TypeScript inference from one declaration. The phantom-typed column builders, SchemaToTypes<S> distributive type, and KickDbRegister module augmentation make KickDbClient resolve to the right row shape everywhere — controllers, repositories, modules — without hand-written interface DB.
One source of truth
The schema file is the only place column types appear. Drift is impossible because there's no second declaration to drift against — the phantom T flows from varchar(255) through SchemaToTypes<typeof schema> to db.selectFrom('users').select(...) automatically.
The pieces
import { table, uuid, varchar, timestamp } from '@forinda/kickjs-db'
export const users = table('users', {
id: uuid().primaryKey().defaultRandom(),
email: varchar(255).notNull().unique(),
createdAt: timestamp().notNull().defaultNow(),
})Each column builder carries a phantom T (its TypeScript value type), a NotNullBrand (set by .notNull() / .primaryKey()), and a GeneratedBrand (set when the database fills the value — defaultRandom(), defaultNow(), serial(), default('...')).
SchemaToTypes<S> distributes over the schema record, picks the phantom T per column, and wraps generated columns in Generated<T> so adopters can omit them on insert:
import { type SchemaToTypes } from '@forinda/kickjs-db'
const schema = { users }
type DB = SchemaToTypes<typeof schema>
// DB resolves to:
// {
// users: {
// id: Generated<string> // uuid generated by DB
// email: string // notNull, no default
// createdAt: Generated<Date> // timestamp().defaultNow()
// }
// }KickDbClient<DB> then types every method (db.selectFrom('users').select('email'), db.insertInto('users').values({...})) against DB['users'].
Wiring the client
// db/client.ts
import { Pool } from 'pg'
import { createDbClient } from '@forinda/kickjs-db'
import { pgAdapter, pgDialect } from '@forinda/kickjs-db-pg'
import * as schema from './schema'
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export const dbClient = createDbClient({
schema,
dialect: pgDialect({ pool }),
events: true,
})
export const migrationAdapter = pgAdapter({ pool })
export type Db = typeof dbClientcreateDbClient<TSchema, DB = SchemaToTypes<TSchema>> infers the DB type directly from the schema parameter. No manual generic — dbClient resolves to KickDbClient<SchemaToTypes<typeof schema>> automatically.
KickDbRegister — bare KickDbClient widening
When a repository or service imports KickDbClient without a generic, you'd normally get the unconstrained surface (unknown row shapes everywhere). The KickDbRegister interface is a module-augmentation registry that names the canonical client globally:
// db/register.ts (manual; or generated — see below)
import type { dbClient } from './client'
declare module '@forinda/kickjs-db' {
interface KickDbRegister {
db: typeof dbClient
}
}After this declaration is in scope, every consumer of bare KickDbClient gets the typed shape:
import { Service, Inject } from '@forinda/kickjs'
import { DB_PRIMARY, type KickDbClient } from '@forinda/kickjs-db'
@Service()
export class UsersRepository {
constructor(@Inject(DB_PRIMARY) private readonly db: KickDbClient) {}
// ^^^^^^^^^^^^
// widens to KickDbClient<SchemaToTypes<typeof schema>>
list() {
return this.db.selectFrom('users').selectAll().execute()
// ^^^^^^^ — typechecked against your schema
}
}Named KickDbRegister rather than Register so the augmentation can't collide with another library augmenting the same name.
Auto-emit via kick typegen
The CLI ships a kick/db typegen plugin that emits the augmentation for you:
kick typegen
# → writes .kickjs/types/kick__db.d.ts containing the KickDbRegister
# declaration pinned to your schema. `kick dev` regenerates on schema
# change.Adopters who let typegen manage this can delete the hand-written register.ts. See Typegen — Disabling specific plugin typegens for the opt-out (typegen.disable: ['kick/db']).
Brand-based nullability
.notNull() and .primaryKey() attach a NotNullBrand symbol to the column type — a phantom intersection that SchemaToTypes<S> reads to decide whether the column resolves to T or T | null:
const t = table('t', {
required: varchar(50).notNull(), // → string
optional: varchar(50), // → string | null
pkey: uuid().primaryKey(), // → string (primaryKey implies notNull)
})Brand-based instead of a class generic so chained methods preserve subclass identity. uuid().primaryKey().defaultRandom() keeps defaultRandom() reachable on UuidBuilder even after .primaryKey() returns this & NotNullBrand — a class generic <TNullable> would collapse the subclass back to the parent.
Self-referencing tables
A column whose foreign key points back at the same table needs lazy thunk resolution because the const binding doesn't exist when the column is built:
import { table, uuid, varchar, type ColumnRef } from '@forinda/kickjs-db'
export const categories = table('categories', {
id: uuid().primaryKey().defaultRandom(),
name: varchar(255).notNull(),
parentId: uuid().references(
(): ColumnRef => categories.id, // ← explicit return type breaks TS7022
{ onDelete: 'set_null' },
),
})ColumnRef is the canonical column reference type. The annotation breaks the categories initializer cycle (without it, TS needs to infer categories while the initializer references it). The thunk fires later — at extract / render / emit time — by which point the const binding has landed.
onDelete / onUpdate are typed FkAction ('cascade' | 'restrict' | 'set_null' | 'set_default' | 'no_action') so typos like 'set null' (with a space) catch at compile time.
Custom column types
customType<T>({ dataType, toDriver, fromDriver }) lets a project introduce a typed column without forking the package — encrypted strings, opaque IDs, citext, PostGIS geometry, custom JSON shapes. See DB Extensions for the full pattern.
PostgreSQL ENUMs
pgEnum(name, ...values) declares an enum once and references it from columns:
import { pgEnum, table, uuid } from '@forinda/kickjs-db'
export const taskStatus = pgEnum('task_status', 'todo', 'in_progress', 'done')
export const tasks = table('tasks', {
id: uuid().primaryKey().defaultRandom(),
status: taskStatus().notNull().default('todo'),
})db.selectFrom('tasks').select('status') types status: 'todo' | 'in_progress' | 'done', not plain string. The snapshot / diff / emit pipeline picks up the declaration and produces:
CREATE TYPE "task_status" AS ENUM ('todo', 'in_progress', 'done');
CREATE TABLE "tasks" (..., "status" "task_status" NOT NULL DEFAULT 'todo', ...);Adding values mid-list emits ALTER TYPE … ADD VALUE … BEFORE … so existing column data round-trips. Removing values requires a manual migration (PG doesn't support DROP VALUE without recreating the type).
Multi-file schemas
Schemas split across files work the same way — import * as schema from './schema' resolves the barrel and SchemaToTypes picks up every table:
src/db/schema/
├── index.ts # barrel: export * from './users', './workspaces', …
├── users.ts # users table only
├── workspaces.ts # workspaces table (FK → users)
├── tasks.ts # tasks table (FK → workspaces)
└── relations.ts # all relations() decls — non-table entries are filtered outSchemaToTypes<S> uses S[K] extends TableDecl<...> to keep tables and drop everything else (relations(), helpers, types). Per-table modules can import { otherTable } from './other' for FK references; relations live in their own file to avoid an import cycle.
Configuring KickDbRegister paths
The plugin auto-detects:
src/db/schema.ts— single-file schemasrc/db/schema/index.ts— barrel folder
If your schema lives elsewhere, run kick typegen --list to see what the kick/db plugin watches and adjust your layout to match the defaults.