The emitter produces contract.d.ts to provide TypeScript types for the DSL and ORM layers. This ADR establishes the complete structure and typing rules for relation fields in generated TypeScript declarations.
Proper relation typing is critical because:
- Developers rely on accurate autocomplete for relation fields in queries
- Type inference must work correctly for nested includes and joins
- The DSL needs to distinguish between scalar columns and relation navigations
- Cardinality (1:1, 1:N, N:1) affects whether a relation is typed as
TorT[] - Nullability from optional relations and LEFT JOINs must propagate correctly
This specification ensures consistency between PSL-first and TS-first emission, and provides clear guidance for building type-safe relation navigation in the DSL.
contract.d.ts exports four namespaces with clear separation of concerns:
- Tables — Storage-level types mapping directly to database columns
- Models — Application-level types with relation fields for developer ergonomics
- Relations — Metadata describing relation structure for the DSL and runtime
- Mappings — Essential connections between models and storage
Given this PSL schema:
model User {
id Int @id @default(autoincrement())
email String @unique
active Boolean @default(true)
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
userId Int
user User @relation(from: [userId], to: [id])
createdAt DateTime @default(now())
}The emitter generates this complete contract.d.ts:
export declare namespace Contract {
// Symbol for metadata property to avoid collisions
const META = Symbol('metadata');
type Meta<T extends { [META]: unknown }> = T[typeof META];
// Metadata interfaces for extensibility
interface TableMetadata<Name extends string> {
name: Name;
}
interface ModelMetadata<Name extends string> {
name: Name;
}
// Base interfaces with metadata
interface TableDef<Name extends string> {
readonly [META]: TableMetadata<Name>;
}
interface ModelDef<Name extends string> {
readonly [META]: ModelMetadata<Name>;
}
// Storage-level types (raw database structure)
export namespace Tables {
export interface user extends TableDef<'user'> {
id: number;
email: string;
active: boolean;
}
export interface post extends TableDef<'post'> {
id: number;
title: string;
user_id: number;
createdAt: Date;
}
}
// Application-level types (with relations)
export namespace Models {
export interface User extends ModelDef<'User'> {
id: number;
email: string;
active: boolean;
posts: Post[]; // 1:N relation
}
export interface Post extends ModelDef<'Post'> {
id: number;
title: string;
userId: number;
createdAt: Date;
user: User; // N:1 relation
}
}
// Relation metadata for DSL and runtime
export namespace Relations {
export interface post {
user: {
to: 'User';
cardinality: 'N:1';
fields: ['userId'];
references: ['id'];
required: true;
};
}
export interface user {
posts: {
to: 'Post';
cardinality: '1:N';
fields: ['id'];
references: ['userId'];
required: false;
};
}
}
// Model-table-field-column mappings
export namespace Mappings {
export interface ModelToTable {
User: 'user';
Post: 'post';
}
export interface TableToModel {
user: 'User';
post: 'Post';
}
export interface FieldToColumn {
User: {
id: 'id';
email: 'email';
active: 'active';
};
Post: {
id: 'id';
title: 'title';
userId: 'user_id';
createdAt: 'createdAt';
};
}
export interface ColumnToField {
user: {
id: 'id';
email: 'email';
active: 'active';
};
post: {
id: 'id';
title: 'title';
user_id: 'userId';
createdAt: 'createdAt';
};
}
}
}
export type Tables = Contract.Tables;
export type Models = Contract.Models;
export type Relations = Contract.Relations;
export type Mappings = Contract.Mappings;This structure enables:
- DSL type safety:
db.user(field-proxyf.id) →Tables.user.id(via Mappings) - ORM includes:
User & { posts: Post[] }(via Models + Relations) - Query results: Proper typing for joins and projections
- Extension support: Branded types for domain-specific values
Tables represent the raw database structure without relations. See the complete example above for the full structure.
Rules:
- Use storage names (table names, column names) from
contract.storage - Include only scalar columns, no relations
- Foreign key columns appear as regular scalars (
user_id: number) - Used for result typing when querying without includes
Models represent the application domain with relation fields. See the complete example above for the full structure.
Rules:
- Use model names and field names from
contract.models - Include relation fields with proper cardinality typing:
1:1andN:1→RelatedModel1:NandN:M→RelatedModel[]
- FK fields use model field names, not storage column names
- Relation fields are always non-null in the base model type
- Optional relations are typed differently (see Nullability Rules below)
Relations provide structural metadata for runtime and DSL. See the complete example above for the full structure.
Rules:
- Keys are storage table names (lowercase)
- Relation names match model field names
toreferences the model namecardinalityis'1:1' | '1:N' | 'N:1' | 'N:M'fieldsandreferencesuse model field namesrequiredindicates if the relation is mandatory
Mappings provide the essential connections between models and storage. See the complete example above for the full structure.
Rules:
ModelToTablemaps model names to storage table namesTableToModelmaps storage table names to model namesFieldToColumnmaps model field names to storage column names per modelColumnToFieldmaps storage column names to model field names per table- All mappings are bidirectional and type-safe
- Used by DSL for
db.userfield-proxy access (f.id) →Tables.user.idtype inference
model User {
id Int @id
profile Profile?
}
model Profile {
id Int @id
user User @relation(from: [userId], to: [id])
userId Int @unique
}Generated types:
export interface User {
id: number
profile: Profile | null // optional side
}
export interface Profile {
id: number
userId: number
user: User // required side
}model User {
id Int @id
posts Post[]
}
model Post {
id Int @id
user User @relation(from: [userId], to: [id])
userId Int
}Generated types:
export interface User {
id: number
posts: Post[] // always array, may be empty
}
export interface Post {
id: number
userId: number
user: User
}Inverse of 1:N; same as the "many" side of 1:N above.
model Post {
id Int @id
categories Category[]
}
model Category {
id Int @id
posts Post[]
}Generated types:
export interface Post {
id: number
categories: Category[]
}
export interface Category {
id: number
posts: Post[]
}Note: Both sides are arrays. The runtime must handle the join table transparently.
- Required relations (N:1 with non-nullable FK, or 1:1 required side):
RelatedModel - Optional relations (1:1 optional side, or nullable FK):
RelatedModel | null - Array relations (1:N, N:M): Always
RelatedModel[], never null (empty array represents no relations)
Result types from queries with joins follow ADR 020 nullability propagation:
// INNER JOIN: relation is non-null if FK is non-null
db.post
.innerJoin(db.user, (f, fns) => fns.eq(f.post.user_id, f.user.id))
.select((f) => ({ /* ... */ }))
// Result: { ..., user: User }
// LEFT JOIN: relation becomes nullable regardless of FK
db.post
.outerLeftJoin(db.user, (f, fns) => fns.eq(f.post.user_id, f.user.id))
.select((f) => ({ /* ... */ }))
// Result: { ..., user: User | null }The DSL layer applies these rules at query construction time, not at model definition time.
The runtime exposes tables through the Db<C> proxy returned by
sql({ context }). Columns are referenced via the field-proxy f argument
passed to .select(...), .where(...), .orderBy(...), etc., which is
scoped to the current builder and typed via the Mappings namespace:
db.user.select((f) => ({
id: f.id, // ColumnRef<number> - maps to Tables.user.id
email: f.email, // ColumnRef<string> - maps to Tables.user.email
}));
// Selecting a relation field directly is a type error; use ORM include.
// Type inference works through mappings and metadata:
// db.user → Tables.user (via TableToModel mapping)
// f.id within db.user.select(...) → Tables.user.id (via ColumnToField mapping)
// Runtime access for dynamic queries using metadata
const user: Contract.Models.User = {
[META]: { name: 'User' },
id: 1,
email: 'test@test.com',
active: true,
posts: [],
};
// Type-safe table name lookup via metadata
const tableName: Contract.Mappings.ModelToTable[typeof user[META]['name']] = 'user';
// Access rich metadata for future extensions
const userMetadata = user[META];
const modelName = userMetadata.name; // 'User'
// Future: const indexes = userMetadata.indexes; // ['user_email_idx']Rules:
- Scalar fields on the
fproxy carryColumnRef<T>for use in SELECT, WHERE, ORDER BY - Relation fields are not exposed on
f; relation navigation lives at the ORM-include layer - Attempting to select a relation directly is a type error
- Type inference uses Mappings namespace to connect
db.tableNametoTables.tableName - Metadata dictionary provides extensible runtime access for dynamic query building
The DSL implementation can use these utility types with metadata dictionary:
// Get the model name from a model instance
type ModelName<M extends ModelDef<string>> = M[META]['name']
// Get the table name from a table interface
type TableName<T extends TableDef<string>> = T[META]['name']
// Get the model type for a table
type ModelForTable<T extends keyof Tables> = Mappings.TableToModel[T]
// Get the table type for a model
type TableForModel<M extends keyof Models> = Mappings.ModelToTable[M]
// Get column type for a field using metadata-based lookup
type ColumnForField<T extends keyof Tables, F extends keyof Mappings.ColumnToField[T]> =
Tables[T][Mappings.ColumnToField[T][F]]
// Example usage in DSL:
type UserModelName = ModelName<Models.User> // 'User'
type UserTableName = TableName<Tables.user> // 'user'
type UserTable = TableForModel<'User'> // 'user'
type UserModel = ModelForTable<'user'> // 'User'
type UserIdColumn = ColumnForField<'user', 'id'> // number
// Runtime usage for dynamic queries with metadata
function buildDynamicQuery<M extends ModelDef<string>>(
db: Db<Contract>,
model: M,
) {
const modelName = model[META].name // Runtime access to metadata
const tableName = Mappings.ModelToTable[modelName] // Type-safe lookup
return db[tableName] // Table proxy keyed by table name on Db<C>
}
// Future: Access extended metadata
function getTableIndexes<T extends TableDef<string>>(table: T): T[META]['indexes'] {
return table[META].indexes // Future extension
}// ORM layer (built on DSL)
orm.query(User)
.include({ posts: true })
.findMany()
// Inferred type: Array<User & { posts: Post[] }>The ORM layer uses the Relations metadata to construct appropriate joins or subqueries.
Extension values receive branded types:
import type { Vector } from '@prisma/pack-pgvector'
export interface document {
id: number
content: string
embedding: Vector<1536> // Branded type from pack
}Rules:
- Extension packs export their branded types
contract.d.tsimports and uses these types- Prevents mixing incompatible extension values (e.g.,
Vector<1536>vsVector<512>)
export interface User {
id: number
email: string
posts: Post[] // relation
user_id?: number // FK from other tables
}Rejected: Mixes concerns, leads to confusion about which fields are queryable vs navigable.
type WithRelations<T, R> = T & RRejected: Requires manual composition, worse DX, harder to infer in complex queries.
export interface Post {
user: User & { __meta: { cardinality: 'N:1' } }
}Rejected: Pollutes application types with metadata, complicates inference.
- Parse contract.json
- Extract models, storage, and relations
- Generate metadata symbol (
META) and interfaces (TableMetadata,ModelMetadata) - Generate base interfaces (
TableDef,ModelDef) with metadata dictionary properties - Generate Tables namespace from storage (extends
TableDef) - Generate Models namespace from models + relations (extends
ModelDef) - Generate Relations namespace from contract.relations using metadata lookups
- Generate Mappings namespace using metadata-based computed property keys
- Write to contract.d.ts
Extension packs must be imported if used:
import type { Vector } from '@prisma/pack-pgvector/types'
import type { Geography } from '@prisma/pack-postgis/types'The emitter scans contract.extensionPacks to determine required imports.
- Interface order follows deterministic contract.json key ordering
- Relation field order within interfaces is deterministic
- Import statements are sorted lexicographically
- PSL → contract.d.ts for various relation patterns
- TS builder → contract.d.ts with identical output
- Extension packs → proper branded type imports
// Relations are typed correctly
const user: Models.User = { id: 1, email: 'a@b.com', posts: [] }
// Cardinality enforced
user.posts = singlePost // error: Type 'Post' is not assignable to 'Post[]'
// Branded types prevent mixing
const vec1: Vector<1536> = ...
const vec2: Vector<512> = ...
vec1 = vec2 // error: incompatible branded types// Query result types infer correctly
const result = await db.user
.outerLeftJoin(db.post, (f, fns) => fns.eq(f.user.id, f.post.user_id))
.select((f) => ({ uid: f.user.id, postId: f.post.id, postTitle: f.post.title }))
.all();
// result: Array<{ uid: number, postId: number | null, postTitle: string | null }>The SQL builder currently projects individual columns rather than whole
relation proxies, so the aspirational post: Tables.post projection is
expressed as the LEFT-JOIN-nullable scalar columns the join exposes; nesting
a full related row stays at the ORM-include layer.
- Clear separation between storage and application domain
- Type safety for relation navigation
- Proper cardinality enforcement
- Extension values are type-safe
- Works with both PSL and TS builder
- Three namespaces increase cognitive load slightly
- Branded types require pack authors to export types
- Generated file size grows with relation count
- Existing code using incomplete types may need updates
- New relation fields in Models may cause type errors if code assumed they didn't exist
- Extension adoption requires updating imports
- ADR 006 — Dual authoring modes
- ADR 007 — Types only emission
- ADR 020 — Result typing and projection inference rules
- ADR 114 — Extension codecs & branded types