Deep Dive: Models and Enrichers
Technical reference for model architecture and schema enrichers in Ignis.
Files:
packages/core/src/base/models/base.tspackages/core/src/base/models/enrichers/*.ts
Quick Reference
| Component | Purpose | Key Features |
|---|---|---|
| BaseEntity | Wraps Drizzle schema | Schema encapsulation, Zod generation, toObject()/toJSON() |
| Schema Enrichers | Add common columns to tables | generateIdColumnDefs(), generateTzColumnDefs(), etc. |
BaseEntity Class
Fundamental building block wrapping a Drizzle ORM schema.
File: packages/core/src/base/models/base.ts
Purpose
| Feature | Description |
|---|---|
| Schema Encapsulation | Holds Drizzle pgTable schema for consistent repository access |
| Metadata | Works with @model decorator to mark database entities |
| Schema Generation | Uses drizzle-zod to generate Zod schemas (SELECT, CREATE, UPDATE) |
| Static Properties | Supports static schema, relations, and TABLE_NAME for cleaner syntax |
| Convenience | Includes toObject() and toJSON() methods |
The @model Decorator
The @model decorator marks a class as a database entity and configures its behavior.
Decorator Options
@model({
type: 'entity' | 'view',
tableName?: string,
skipMigrate?: boolean,
settings?: {
hiddenProperties?: string[], // Properties to exclude from query results
defaultFilter?: TFilter, // Filter applied to all repository queries
}
})| Option | Type | Description |
|---|---|---|
type | 'entity' | 'view' | Entity type - 'entity' for tables, 'view' for database views |
tableName | string | Optional custom table name (defaults to class name) |
skipMigrate | boolean | Skip this model during schema migrations |
settings.hiddenProperties | string[] | Array of property names to exclude from all repository query results |
settings.defaultFilter | TFilter | Filter automatically applied to all repository queries (see Default Filter) |
Hidden Properties
Hidden properties are excluded at the SQL level - they are never fetched from the database when querying through repositories. This provides:
- Security: Sensitive data like passwords are never accidentally exposed
- Performance: Less data transferred from database
- Consistency: Hidden properties are excluded from ALL repository operations
import { pgTable, text } from 'drizzle-orm/pg-core';
import { BaseEntity, model, generateIdColumnDefs } from '@venizia/ignis';
@model({
type: 'entity',
settings: {
hiddenProperties: ['password', 'secret'], // Never returned via repository
},
})
export class User extends BaseEntity<typeof User.schema> {
static override schema = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
email: text('email').notNull(),
password: text('password'), // Hidden - never in query results
secret: text('secret'), // Hidden - never in query results
});
}Behavior
| Operation | Hidden Properties |
|---|---|
find(), findOne(), findById() | Excluded from SELECT |
create(), createAll() | Excluded from RETURNING |
updateById(), updateAll() | Excluded from RETURNING |
deleteById(), deleteAll() | Excluded from RETURNING |
count(), existsWith() | Can filter by hidden fields |
| Direct connector query | Included (bypasses repository) |
Important Notes
- Hidden properties can still be used in
whereclauses for filtering - Data is still stored in the database - only excluded from query results
- Use direct connector queries when you need to access hidden data:
// Repository query - password/secret NOT included
const user = await userRepo.findById({ id: '123' });
// user = { id: '123', email: 'john@example.com' }
// Direct connector query - ALL fields included
const connector = userRepo.getConnector();
const [fullUser] = await connector
.select()
.from(User.schema)
.where(eq(User.schema.id, '123'));
// fullUser = { id: '123', email: 'john@example.com', password: 'hashed...', secret: '...' }Default Filter
Default filters are automatically applied to all repository queries for a model. This is useful for:
- Soft Delete: Automatically exclude deleted records
- Multi-Tenancy: Isolate data by tenant
- Active Records: Filter to active/non-expired records
- Query Limits: Prevent unbounded queries
@model({
type: 'entity',
settings: {
defaultFilter: {
where: { isDeleted: false }, // Applied to all queries
limit: 100, // Prevents unbounded queries
},
},
})
export class Post extends BaseEntity<typeof Post.schema> {
static override schema = postTable;
}Behavior
| Operation | Default Filter |
|---|---|
find(), findOne(), findById() | Applied to WHERE clause |
count(), existsWith() | Applied to WHERE clause |
updateById(), updateAll() | Applied to WHERE clause |
deleteById(), deleteAll() | Applied to WHERE clause |
create(), createAll() | Not applied |
Bypassing
Use shouldSkipDefaultFilter: true to bypass:
// Normal query - includes default filter
await postRepo.find({ filter: {} });
// WHERE isDeleted = false LIMIT 100
// Admin query - bypass default filter
await postRepo.find({
filter: {},
options: { shouldSkipDefaultFilter: true }
});
// No WHERE clause (includes deleted)TIP
See Default Filter for full documentation including merge strategies and common patterns.
Definition Patterns
BaseEntity supports two patterns for defining models:
Pattern 1: Static Properties (Recommended)
Define schema and relations as static properties:
import { pgTable, text } from 'drizzle-orm/pg-core';
import { BaseEntity, model, generateIdColumnDefs, createRelations } from '@venizia/ignis';
// Define table schema
export const userTable = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
name: text('name').notNull(),
email: text('email').notNull(),
});
// Define relations
export const userRelations = createRelations({
source: userTable,
relations: [],
});
// Entity class with static properties
@model({ type: 'entity' })
export class User extends BaseEntity<typeof User.schema> {
static override schema = userTable;
static override relations = () => userRelations.definitions;
static override TABLE_NAME = 'User';
}Benefits:
- Schema and relations are auto-resolved by repositories
- No need to pass
relationsin repository constructor - Cleaner, more declarative syntax
Pattern 2: Constructor-Based (Legacy)
Pass schema in constructor:
@model({ type: 'entity' })
export class User extends BaseEntity<typeof userTable> {
constructor() {
super({ name: 'User', schema: userTable });
}
}Static Properties
| Property | Type | Description |
|---|---|---|
schema | TTableSchemaWithId | Drizzle table schema defined with pgTable() |
relations | TValueOrResolver<Array<TRelationConfig>> | Relation definitions (can be a function for lazy loading) |
TABLE_NAME | string | undefined | Optional table name (defaults to class name if not set) |
IEntity Interface
Models implementing static properties conform to the IEntity interface:
interface IEntity<Schema extends TTableSchemaWithId = TTableSchemaWithId> {
TABLE_NAME?: string;
schema: Schema;
relations?: TValueOrResolver<Array<TRelationConfig>>;
}Instance Methods
| Method | Description |
|---|---|
getSchema({ type }) | Get Zod schema for validation (SELECT, CREATE, UPDATE) |
toObject() | Convert to plain object |
toJSON() | Convert to JSON string |
Class Definition
export class BaseEntity<Schema extends TTableSchemaWithId = TTableSchemaWithId>
extends BaseHelper
implements IEntity<Schema>
{
// Instance properties
name: string;
schema: Schema;
// Static properties - override in subclass
static schema: TTableSchemaWithId;
static relations?: TValueOrResolver<Array<TRelationConfig>>;
static TABLE_NAME?: string; // Optional, defaults to class name
// Static singleton for schemaFactory - shared across all instances
// Performance optimization: avoids creating new factory per entity
private static _schemaFactory?: ReturnType<typeof createSchemaFactory>;
protected static get schemaFactory(): ReturnType<typeof createSchemaFactory> {
return (BaseEntity._schemaFactory ??= createSchemaFactory());
}
// Constructor supports both patterns
constructor(opts?: { name?: string; schema?: Schema }) {
const ctor = new.target as typeof BaseEntity;
// Use explicit TABLE_NAME if defined, otherwise fall back to class name
const name = opts?.name ?? ctor.TABLE_NAME ?? ctor.name;
super({ scope: name });
this.name = name;
this.schema = opts?.schema || (ctor.schema as Schema);
}
getSchema(opts: { type: TSchemaType }) {
const factory = BaseEntity.schemaFactory; // Uses static singleton
switch (opts.type) {
case SchemaTypes.CREATE:
return factory.createInsertSchema(this.schema);
case SchemaTypes.UPDATE:
return factory.createUpdateSchema(this.schema);
case SchemaTypes.SELECT:
return factory.createSelectSchema(this.schema);
default:
throw getError({
message: `[getSchema] Invalid schema type | type: ${opts.type}`,
});
}
}
toObject() {
return { ...this };
}
toJSON() {
return this.toObject();
}
}Performance Note: The schemaFactory is implemented as a static lazy singleton, meaning it's created once and shared across all BaseEntity instances. This avoids the overhead of creating a new drizzle-zod schema factory for every entity instantiation.
Schema Enrichers
Enrichers are helper functions located in packages/core/src/base/models/enrichers/ that return an object of Drizzle ORM column definitions. They are designed to be spread into a pgTable definition to quickly add common, standardized fields to your models.
Available Enrichers
| Enricher Function | Purpose |
|---|---|
generateIdColumnDefs | Adds a primary key id column (string UUID or numeric serial). |
generateTzColumnDefs | Adds createdAt, modifiedAt, and deletedAt timestamp columns with timezone support. |
generateUserAuditColumnDefs | Adds createdBy and modifiedBy columns to track user audit information. |
generateDataTypeColumnDefs | Adds generic data type columns (dataType, nValue, tValue, bValue, jValue, boValue) for flexible data storage. |
generatePrincipalColumnDefs | Adds polymorphic fields for associating with different principal types. |
extraUserColumns | Adds common fields for a user model, such as realm, status, type, activatedAt, lastLoginAt, and parentId. Import from @venizia/ignis. |
Example Usage
import { pgTable, text } from 'drizzle-orm/pg-core';
import {
generateIdColumnDefs,
generateTzColumnDefs,
generateUserAuditColumnDefs,
} from '@venizia/ignis';
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateTzColumnDefs(),
...generateUserAuditColumnDefs({ created: { dataType: 'string' }, modified: { dataType: 'string' } }),
name: text('name').notNull(),
});Detailed Enricher Reference
generateIdColumnDefs
Adds a primary key id column with support for string UUID, integer, or big integer types with full TypeScript type inference.
File: packages/core/src/base/models/enrichers/id.enricher.ts
Signature
generateIdColumnDefs<Opts extends TIdEnricherOptions | undefined>(
opts?: Opts,
): TIdColumnDef<Opts>Options (TIdEnricherOptions)
type TIdEnricherOptions = {
id?: { columnName?: string } & (
| { dataType: 'string'; generator?: () => string } // Optional custom ID generator
| {
dataType: 'number';
sequenceOptions?: PgSequenceOptions;
}
| {
dataType: 'big-number';
numberMode: 'number' | 'bigint'; // Required for big-number
sequenceOptions?: PgSequenceOptions;
}
);
};Default values:
dataType:'number'(auto-incrementing integer)columnName:'id'
Generated Columns
| Data Type | Column Type | Constraints | Description |
|---|---|---|---|
'string' | text | Primary Key, Default: crypto.randomUUID() | Text column with customizable ID generator (default: UUID) |
'number' | integer | Primary Key, GENERATED ALWAYS AS IDENTITY | Auto-incrementing integer |
'big-number' | bigint | Primary Key, GENERATED ALWAYS AS IDENTITY | Auto-incrementing big integer (mode: 'number' or 'bigint') |
Type Inference
The function provides full TypeScript type inference based on the configuration options:
// Type aliases for readability
type TStringIdCol = HasRuntimeDefault<
HasDefault<IsPrimaryKey<NotNull<PgTextBuilderInitial<'id', [string, ...string[]]>>>>
>;
type TNumberIdCol = IsIdentity<IsPrimaryKey<NotNull<PgIntegerBuilderInitial<'id'>>>, 'always'>;
type TBigInt53IdCol = IsIdentity<IsPrimaryKey<NotNull<PgBigInt53BuilderInitial<'id'>>>, 'always'>;
type TBigInt64IdCol = IsIdentity<IsPrimaryKey<NotNull<PgBigInt64BuilderInitial<'id'>>>, 'always'>;
type TIdColumnDef<Opts extends TIdEnricherOptions | undefined> = Opts extends {
id: infer IdOpts;
}
? IdOpts extends { dataType: 'string' }
? { id: TStringIdCol }
: IdOpts extends { dataType: 'number' }
? { id: TNumberIdCol }
: IdOpts extends { dataType: 'big-number' }
? IdOpts extends { numberMode: 'number' }
? { id: TBigInt53IdCol }
: { id: TBigInt64IdCol }
: { id: TNumberIdCol }
: { id: TNumberIdCol };This ensures that TypeScript correctly infers the exact column type based on your configuration.
Usage Examples
Default (auto-incrementing integer):
import { pgTable, text } from 'drizzle-orm/pg-core';
import { generateIdColumnDefs } from '@venizia/ignis';
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs(),
name: text('name').notNull(),
});
// Generates: id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITYText-based string ID (UUID by default):
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
name: text('name').notNull(),
});
// Generates: id text PRIMARY KEY with $defaultFn(() => crypto.randomUUID())
// Uses text column for maximum database compatibilityCustom ID generator (e.g., nanoid, cuid):
import { nanoid } from 'nanoid';
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({
id: {
dataType: 'string',
generator: () => nanoid(), // Custom generator function
},
}),
name: text('name').notNull(),
});
// Generates: id text PRIMARY KEY with $defaultFn(() => nanoid())Auto-incrementing integer with sequence options:
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({
id: {
dataType: 'number',
sequenceOptions: { startWith: 1000, increment: 1 },
},
}),
name: text('name').notNull(),
});
// Generates: id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 1000 INCREMENT BY 1)Big number with JavaScript number mode (up to 2^53-1):
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({
id: {
dataType: 'big-number',
numberMode: 'number', // Required field
sequenceOptions: { startWith: 1, increment: 1 },
},
}),
name: text('name').notNull(),
});
// Generates: id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY
// Type-safe: Returns PgBigInt53BuilderInitial (safe for JavaScript numbers)Big number with BigInt mode (for values > 2^53-1):
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({
id: {
dataType: 'big-number',
numberMode: 'bigint', // Required field
sequenceOptions: { startWith: 1, increment: 1 },
},
}),
name: text('name').notNull(),
});
// Generates: id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY
// Type-safe: Returns PgBigInt64BuilderInitial (requires BigInt in JavaScript)Important Notes
- Text Column: When using
dataType: 'string', atextcolumn is used for maximum database compatibility. This allows you to use any ID format (UUID, nanoid, cuid, etc.) without database-specific constraints. - Custom Generator: You can provide a custom
generatorfunction to generate IDs. Default iscrypto.randomUUID(). - Type Safety: The return type is fully inferred based on your options, providing better autocomplete and type checking
- Big Number Mode: For
dataType: 'big-number', thenumberModefield is required to specify whether to use JavaScriptnumber(up to 2^53-1) orbigint(for larger values) - Sequence Options: Available for
numberandbig-numbertypes to customize identity generation behavior
generateTzColumnDefs
Adds timestamp columns for tracking entity creation, modification, and soft deletion.
File: packages/core/src/base/models/enrichers/tz.enricher.ts
Signature
generateTzColumnDefs(opts?: TTzEnricherOptions): TTzEnricherResultOptions (TTzEnricherOptions)
type TTzEnricherOptions = {
created?: { columnName: string; withTimezone: boolean };
modified?: { enable: false } | { enable?: true; columnName: string; withTimezone: boolean };
deleted?: { enable: false } | { enable?: true; columnName: string; withTimezone: boolean };
};The modified and deleted options use a discriminated union pattern:
- When
enable: false, no other properties are needed - When
enable: true(or omitted),columnNameandwithTimezoneare required
Default values:
created:{ columnName: 'created_at', withTimezone: true }modified:{ enable: true, columnName: 'modified_at', withTimezone: true }deleted:{ enable: false }(disabled by default)
Generated Columns
| Column | Type | Constraints | Default | Description |
|---|---|---|---|---|
createdAt | timestamp | NOT NULL | now() | When the record was created (always included) |
modifiedAt | timestamp | NOT NULL | now() | When the record was last modified (optional, enabled by default) |
deletedAt | timestamp | nullable | null | When the record was soft-deleted (optional, disabled by default) |
Usage Examples
Basic usage (default columns):
import { pgTable, text } from 'drizzle-orm/pg-core';
import { generateTzColumnDefs } from '@venizia/ignis';
export const myTable = pgTable('MyTable', {
...generateTzColumnDefs(),
name: text('name').notNull(),
});
// Generates: createdAt, modifiedAt (deletedAt is disabled by default)Enable soft delete:
export const myTable = pgTable('MyTable', {
...generateTzColumnDefs({
deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
}),
name: text('name').notNull(),
});
// Generates: createdAt, modifiedAt, deletedAtCustom column names:
export const myTable = pgTable('MyTable', {
...generateTzColumnDefs({
created: { columnName: 'created_date', withTimezone: true },
modified: { enable: true, columnName: 'updated_date', withTimezone: true },
deleted: { enable: true, columnName: 'removed_date', withTimezone: true },
}),
name: text('name').notNull(),
});Without timezone:
export const myTable = pgTable('MyTable', {
...generateTzColumnDefs({
created: { columnName: 'created_at', withTimezone: false },
modified: { enable: true, columnName: 'modified_at', withTimezone: false },
deleted: { enable: true, columnName: 'deleted_at', withTimezone: false },
}),
name: text('name').notNull(),
});Minimal setup (only createdAt):
export const myTable = pgTable('MyTable', {
...generateTzColumnDefs({
modified: { enable: false },
deleted: { enable: false },
}),
name: text('name').notNull(),
});
// Generates: createdAt onlySoft Delete Pattern
The deletedAt column enables the soft delete pattern, where records are marked as deleted rather than physically removed from the database.
Example soft delete query:
import { eq, isNull } from 'drizzle-orm';
// Soft delete: set deletedAt timestamp
await db.update(myTable)
.set({ deletedAt: new Date() })
.where(eq(myTable.id, id));
// Query only active (non-deleted) records
const activeRecords = await db.select()
.from(myTable)
.where(isNull(myTable.deletedAt));
// Query deleted records
const deletedRecords = await db.select()
.from(myTable)
.where(isNotNull(myTable.deletedAt));
// Restore a soft-deleted record
await db.update(myTable)
.set({ deletedAt: null })
.where(eq(myTable.id, id));Type Inference
The enricher provides proper TypeScript type inference:
type TTzEnricherResult<ColumnDefinitions extends TColumnDefinitions = TColumnDefinitions> = {
createdAt: PgTimestampBuilderInitial<string> & NotNull & HasDefault;
modifiedAt?: PgTimestampBuilderInitial<string> & NotNull & HasDefault;
deletedAt?: PgTimestampBuilderInitial<string>;
};generateUserAuditColumnDefs
Adds createdBy and modifiedBy columns to track which user created or modified a record.
File: packages/core/src/base/models/enrichers/user-audit.enricher.ts
Signature
generateUserAuditColumnDefs(opts?: TUserAuditEnricherOptions): {
createdBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
modifiedBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
}Options (TUserAuditEnricherOptions)
type TUserAuditColumnOpts = {
dataType: 'string' | 'number'; // Required - type of user ID
columnName: string; // Column name in database
allowAnonymous?: boolean; // Allow null user ID (default: true)
};
type TUserAuditEnricherOptions = {
created?: TUserAuditColumnOpts;
modified?: TUserAuditColumnOpts;
};Default values:
created:{ dataType: 'number', columnName: 'created_by', allowAnonymous: true }modified:{ dataType: 'number', columnName: 'modified_by', allowAnonymous: true }
allowAnonymous Behavior
The allowAnonymous option controls whether the enricher requires an authenticated user context:
allowAnonymous | No Context | No User ID | Has User ID |
|---|---|---|---|
true (default) | Returns null | Returns null | Returns user ID |
false | Throws error | Throws error | Returns user ID |
When to use allowAnonymous: false:
- Sensitive audit trails that must track the responsible user
- Tables where anonymous operations should be forbidden
- Compliance requirements that mandate user attribution
When to use allowAnonymous: true (default):
- Background jobs, migrations, or seed scripts without user context
- System-generated records
- Tables that allow both authenticated and anonymous operations
Generated Columns
| Column | Data Type | Column Name | Description |
|---|---|---|---|
createdBy | integer or text | created_by | User ID who created the record |
modifiedBy | integer or text | modified_by | User ID who last modified the record |
Validation
The enricher validates the dataType option and throws an error for invalid values:
// ✅ Valid
generateUserAuditColumnDefs({ created: { dataType: 'number', columnName: 'created_by' } });
generateUserAuditColumnDefs({ created: { dataType: 'string', columnName: 'created_by' } });
// ❌ Invalid - throws error
generateUserAuditColumnDefs({ created: { dataType: 'uuid', columnName: 'created_by' } });
// Error: [enrichUserAudit] Invalid dataType for 'createdBy' | value: uuid | valid: ['number', 'string']Usage Examples
Default (integer user IDs):
import { pgTable, text } from 'drizzle-orm/pg-core';
import { generateIdColumnDefs, generateUserAuditColumnDefs } from '@venizia/ignis';
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs(),
...generateUserAuditColumnDefs(),
name: text('name').notNull(),
});
// Generates:
// createdBy: integer('created_by')
// modifiedBy: integer('modified_by')String user IDs (UUID):
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateUserAuditColumnDefs({
created: { dataType: 'string', columnName: 'created_by' },
modified: { dataType: 'string', columnName: 'modified_by' },
}),
name: text('name').notNull(),
});
// Generates:
// createdBy: text('created_by')
// modifiedBy: text('modified_by')Custom column names:
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs(),
...generateUserAuditColumnDefs({
created: { dataType: 'number', columnName: 'author_id' },
modified: { dataType: 'number', columnName: 'editor_id' },
}),
name: text('name').notNull(),
});
// Generates:
// createdBy: integer('author_id')
// modifiedBy: integer('editor_id')Requiring authenticated user (allowAnonymous: false):
// For sensitive tables that must track the responsible user
export const auditLogTable = pgTable('AuditLog', {
...generateIdColumnDefs(),
...generateUserAuditColumnDefs({
created: { dataType: 'number', columnName: 'created_by', allowAnonymous: false },
modified: { dataType: 'number', columnName: 'modified_by', allowAnonymous: false },
}),
action: text('action').notNull(),
details: text('details'),
});
// If no authenticated user context is available, throws:
// Error: [getCurrentUserId] Invalid request context to identify user | columnName: createdBy | allowAnonymous: falseSchema Utilities
snakeToCamel
Converts a Zod schema from snake_case to camelCase, transforming both the schema shape and runtime data.
File: packages/core/src/base/models/common/types.ts
Signature
snakeToCamel<T extends z.ZodRawShape>(shape: T): z.ZodEffects<...>Purpose
This utility is useful when working with databases that use snake_case column names but you want to work with camelCase in your TypeScript code. It creates a Zod schema that:
- Accepts snake_case input (validates against original schema)
- Transforms the data to camelCase at runtime
- Validates the transformed data against a camelCase schema
Usage Example
import { z } from 'zod';
import { snakeToCamel } from '@venizia/ignis';
// Define schema with snake_case fields
const userSnakeSchema = {
user_id: z.number(),
first_name: z.string(),
last_name: z.string(),
created_at: z.date(),
is_active: z.boolean(),
};
// Convert to camelCase schema
const userCamelSchema = snakeToCamel(userSnakeSchema);
// Input data from database (snake_case)
const dbData = {
user_id: 123,
first_name: 'John',
last_name: 'Doe',
created_at: new Date(),
is_active: true,
};
// Parse and transform to camelCase
const result = userCamelSchema.parse(dbData);
// Result is automatically camelCase:
console.log(result);
// {
// userId: 123,
// firstName: 'John',
// lastName: 'Doe',
// createdAt: Date,
// isActive: true
// }Real-world Example
Use case: API endpoint that accepts snake_case but works with camelCase internally
import { BaseController, controller, snakeToCamel, HTTP } from '@venizia/ignis';
import { z } from '@hono/zod-openapi';
const createUserSchema = snakeToCamel({
first_name: z.string().min(1),
last_name: z.string().min(1),
email_address: z.string().email(),
phone_number: z.string().optional(),
});
@controller({ path: '/users' })
export class UserController extends BaseController {
override binding() {
this.bindRoute({
configs: {
path: '/',
method: 'post',
request: {
body: {
content: {
'application/json': { schema: createUserSchema },
},
},
},
},
}).to({
handler: async (ctx) => {
// Request body is automatically camelCase
const data = ctx.req.valid('json');
// data = {
// firstName: string,
// lastName: string,
// emailAddress: string,
// phoneNumber?: string
// }
// Work with camelCase data
console.log(data.firstName); // ✅ TypeScript knows this exists
console.log(data.first_name); // ❌ TypeScript error
return ctx.json({ success: true }, HTTP.ResultCodes.RS_2.Ok);
},
});
}
}Type Transformation
The utility includes sophisticated TypeScript type transformation:
type TSnakeToCamelCase<S extends string> =
S extends `${infer T}_${infer U}`
? `${T}${Capitalize<TSnakeToCamelCase<U>>}`
: S;
type TCamelCaseKeys<T extends z.ZodRawShape> = {
[K in keyof T as K extends string ? TSnakeToCamelCase<K> : K]:
T[K] extends z.ZodType<infer U> ? z.ZodType<U> : T[K];
};This ensures full type safety: TypeScript will know that first_name becomes firstName, created_at becomes createdAt, etc.
Validation
The schema validates twice for safety:
- First validation: Checks that input matches snake_case schema
- Transformation: Converts keys from snake_case to camelCase
- Second validation: Validates transformed data against camelCase schema
// If validation fails at any step, you get clear error messages
const invalidData = {
user_id: 'not-a-number', // ❌ Fails first validation
first_name: 'John',
last_name: 'Doe',
};
try {
userCamelSchema.parse(invalidData);
} catch (error) {
// ZodError with clear message about user_id expecting number
}Notes
- Built on top of
keysToCamel()andtoCamel()utilities from@venizia/ignis-helpers - Recursively handles nested objects
- Preserves array structures
- Works seamlessly with Zod's other features (refinements, transforms, etc.)
See Also
Related Concepts:
- Models Guide - Creating models tutorial
- Repositories - Using models in repositories
- DataSources - Database connections
References:
- Repositories API - Data access layer
- Relations - Model relationships
- Filter System - Querying models
External Resources:
- Drizzle ORM Documentation - Schema definition guide
- PostgreSQL Data Types - Column types
Best Practices:
- Data Modeling - Schema design patterns
Tutorials:
- Building a CRUD API - Model examples
- E-commerce API - Models with relations