Schema builders
Use ttest utilities to author your own type-safe DSL or API.
duck-ttest is built for the case where types are the contract — schema generators, ORMs, route registries, validation libraries. This guide is a worked example: a minimal type-safe column-builder using Brand, InferSchema, and the assertion API.
Goal
Author a small DSL:
const users = table('users', {
id: int().primaryKey().autoIncrement(),
email: text().notNull(),
name: text(),
})
type User = typeof users.row
// ^? { id?: number; email: string; name: string | null }
The type at users.row should be inferred entirely from the builder calls — no manual type annotation. We'll use InferSchema to emit the row shape, and ttest assertions to lock the contract in tests.
Strategy 1: SQL-string carrier
The simplest approach: encode the column DDL as a phantom string-literal type, then run InferSchema over a CREATE TABLE template literal.
import type { InferSchema } from '@gentleduck/ttest/sql'
import type { Brand } from '@gentleduck/ttest/brand'
type Column<S extends string> = Brand<{}, S>
function col<S extends string>(ddl: S): Column<S> { return {} as Column<S> }
const idColDef = col('id INT PRIMARY KEY AUTOINCREMENT')
const emailColDef = col('email TEXT NOT NULL')
const nameColDef = col('name TEXT')
type IdSchema = InferSchema<'CREATE TABLE users (id INT PRIMARY KEY AUTOINCREMENT)'>
// ^? { id?: number }
This works because InferSchema is a pure type-level parser — it doesn't need a real database.
Strategy 2: Lock the contract in tests
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import type { InferSchema } from '@gentleduck/ttest/sql'
type UserRow = InferSchema<
'CREATE TABLE users (id INT PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT)'
>
type _ = [
AssertTrue<Equal<UserRow['id'], number | undefined>, 'id is optional number'>,
AssertTrue<Equal<UserRow['email'], string>, 'email is required string'>,
AssertTrue<Equal<UserRow['name'], string | null>, 'name is nullable'>,
]
If the DDL parser ever regresses, the assertion breaks at type-check time — before the schema reaches production.
Strategy 3: Narrow at the boundary
InferSchema['name'] is string | null. Callers who want non-null name go through a refinement:
import type { NonNullish } from '@gentleduck/ttest/primitive'
function withName(row: UserRow & { name: NonNullish<UserRow['name']> }) {
return row.name.length
}
Or with a key-level refinement:
import type { RequiredBy } from '@gentleduck/ttest/object'
type WithName = RequiredBy<UserRow, 'name'>
// ^? { id?: number; email: string; name: string | null } // still nullable because the source type allows null
For "remove the null too", combine with NonNullable:
type WithNameNonNull = Omit<UserRow, 'name'> & { name: NonNullable<UserRow['name']> }
Strategy 4: Cross-table inference
When schemas reference each other, use ExtractReferences and ResolveFields:
import type { ExtractReferences, ResolveFields } from '@gentleduck/ttest/sql'
type UserRefCol = ExtractReferences<'user_id INT REFERENCES users(id)'>
// → Ref<'users', 'id'>
type Schemas = { users: UserRow; /* posts: PostRow ... */ }
type Resolved = ResolveFields<{ user_id: UserRefCol }, Schemas>
// ^? { user_id: number }
This is how a multi-table builder propagates foreign-key types end-to-end.
Anti-patterns
Asserting the full inferred row in one shot
// Brittle — every key change rewrites the assertion.
type _ = AssertTrue<
Equal<UserRow, { id?: number; email: string; name: string | null }>,
'full row'
>
The whole-row equality fails noisily when a single column changes. Split into per-key assertions so the failure points at the column that drifted:
// Each assertion is independent.
type _ = [
AssertTrue<Equal<UserRow['id'], number | undefined>, 'id'>,
AssertTrue<Equal<UserRow['email'], string>, 'email'>,
AssertTrue<Equal<UserRow['name'], string | null>, 'name'>,
]
Hand-rolling the row type alongside the DDL
// Don't.
type UserRow = { id?: number; email: string; name: string | null }
const usersDDL = 'CREATE TABLE users (id INT PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT)'
The two definitions can drift silently. Either derive the type from the DDL via InferSchema, or derive the DDL from the type via a builder DSL. Never both.
What to test
For any schema builder, keep these assertions:
import type { AssertFalse, AssertTrue, HasKeyWithType } from '@gentleduck/ttest/equality'
import type { IsAny, IsOptional } from '@gentleduck/ttest/primitive'
- Required keys are required.
AssertTrue<HasKeyWithType<'email', string, Row>, ...>. - Optional keys are optional.
AssertTrue<IsOptional<Row['id']>, ...>(type-level; useIsSQLOptionalfrom@gentleduck/ttest/sqlfor SQL DDL strings). - Foreign keys resolve. Equal-check against the referenced row's column type.
- No leaked
any.AssertFalse<IsAny<Row[K]>, ...>for everyK.
See also
- API / sql — the full parsing pipeline.
- API / brand — phantom-type carriers for builder DSLs.
- Guides / Catching regressions — wiring schema tests into CI.