Skip to content

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

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

ts
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

ts
// 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 dbClient

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

ts
// 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:

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

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

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

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

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

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

text
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 out

SchemaToTypes<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 schema
  • src/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.