Skip to content

Deep Dive: Models and Enrichers

Technical reference for model architecture and schema enrichers in Ignis.

Files:

  • packages/core/src/base/models/base.ts
  • packages/core/src/base/models/enrichers/*.ts

Quick Reference

ComponentPurposeKey Features
BaseEntityWraps Drizzle schemaSchema encapsulation, Zod generation, toObject()/toJSON()
Schema EnrichersAdd common columns to tablesgenerateIdColumnDefs(), generateTzColumnDefs(), etc.

BaseEntity Class

Fundamental building block wrapping a Drizzle ORM schema.

File: packages/core/src/base/models/base.ts

Purpose

FeatureDescription
Schema EncapsulationHolds Drizzle pgTable schema for consistent repository access
MetadataWorks with @model decorator to mark database entities
Schema GenerationUses drizzle-zod to generate Zod schemas (SELECT, CREATE, UPDATE)
Static PropertiesSupports static schema, relations, and TABLE_NAME for cleaner syntax
ConvenienceIncludes toObject() and toJSON() methods

The @model Decorator

The @model decorator marks a class as a database entity and configures its behavior.

Decorator Options

typescript
@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
  }
})
OptionTypeDescription
type'entity' | 'view'Entity type - 'entity' for tables, 'view' for database views
tableNamestringOptional custom table name (defaults to class name)
skipMigratebooleanSkip this model during schema migrations
settings.hiddenPropertiesstring[]Array of property names to exclude from all repository query results
settings.defaultFilterTFilterFilter 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
typescript
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

OperationHidden 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 queryIncluded (bypasses repository)

Important Notes

  • Hidden properties can still be used in where clauses 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:
typescript
// 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
typescript
@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

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

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

Define schema and relations as static properties:

typescript
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 relations in repository constructor
  • Cleaner, more declarative syntax

Pattern 2: Constructor-Based (Legacy)

Pass schema in constructor:

typescript
@model({ type: 'entity' })
export class User extends BaseEntity<typeof userTable> {
  constructor() {
    super({ name: 'User', schema: userTable });
  }
}

Static Properties

PropertyTypeDescription
schemaTTableSchemaWithIdDrizzle table schema defined with pgTable()
relationsTValueOrResolver<Array<TRelationConfig>>Relation definitions (can be a function for lazy loading)
TABLE_NAMEstring | undefinedOptional table name (defaults to class name if not set)

IEntity Interface

Models implementing static properties conform to the IEntity interface:

typescript
interface IEntity<Schema extends TTableSchemaWithId = TTableSchemaWithId> {
  TABLE_NAME?: string;
  schema: Schema;
  relations?: TValueOrResolver<Array<TRelationConfig>>;
}

Instance Methods

MethodDescription
getSchema({ type })Get Zod schema for validation (SELECT, CREATE, UPDATE)
toObject()Convert to plain object
toJSON()Convert to JSON string

Class Definition

typescript
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 FunctionPurpose
generateIdColumnDefsAdds a primary key id column (string UUID or numeric serial).
generateTzColumnDefsAdds createdAt, modifiedAt, and deletedAt timestamp columns with timezone support.
generateUserAuditColumnDefsAdds createdBy and modifiedBy columns to track user audit information.
generateDataTypeColumnDefsAdds generic data type columns (dataType, nValue, tValue, bValue, jValue, boValue) for flexible data storage.
generatePrincipalColumnDefsAdds polymorphic fields for associating with different principal types.
extraUserColumnsAdds common fields for a user model, such as realm, status, type, activatedAt, lastLoginAt, and parentId. Import from @venizia/ignis.

Example Usage

typescript
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

typescript
generateIdColumnDefs<Opts extends TIdEnricherOptions | undefined>(
  opts?: Opts,
): TIdColumnDef<Opts>

Options (TIdEnricherOptions)

typescript
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 TypeColumn TypeConstraintsDescription
'string'textPrimary Key, Default: crypto.randomUUID()Text column with customizable ID generator (default: UUID)
'number'integerPrimary Key, GENERATED ALWAYS AS IDENTITYAuto-incrementing integer
'big-number'bigintPrimary Key, GENERATED ALWAYS AS IDENTITYAuto-incrementing big integer (mode: 'number' or 'bigint')

Type Inference

The function provides full TypeScript type inference based on the configuration options:

typescript
// 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):

typescript
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 IDENTITY

Text-based string ID (UUID by default):

typescript
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 compatibility

Custom ID generator (e.g., nanoid, cuid):

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

typescript
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):

typescript
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):

typescript
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', a text column 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 generator function to generate IDs. Default is crypto.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', the numberMode field is required to specify whether to use JavaScript number (up to 2^53-1) or bigint (for larger values)
  • Sequence Options: Available for number and big-number types 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

typescript
generateTzColumnDefs(opts?: TTzEnricherOptions): TTzEnricherResult

Options (TTzEnricherOptions)

typescript
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), columnName and withTimezone are 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

ColumnTypeConstraintsDefaultDescription
createdAttimestampNOT NULLnow()When the record was created (always included)
modifiedAttimestampNOT NULLnow()When the record was last modified (optional, enabled by default)
deletedAttimestampnullablenullWhen the record was soft-deleted (optional, disabled by default)

Usage Examples

Basic usage (default columns):

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

typescript
export const myTable = pgTable('MyTable', {
  ...generateTzColumnDefs({
    deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
  }),
  name: text('name').notNull(),
});

// Generates: createdAt, modifiedAt, deletedAt

Custom column names:

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

typescript
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):

typescript
export const myTable = pgTable('MyTable', {
  ...generateTzColumnDefs({
    modified: { enable: false },
    deleted: { enable: false },
  }),
  name: text('name').notNull(),
});

// Generates: createdAt only

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

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

typescript
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

typescript
generateUserAuditColumnDefs(opts?: TUserAuditEnricherOptions): {
  createdBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
  modifiedBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
}

Options (TUserAuditEnricherOptions)

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

allowAnonymousNo ContextNo User IDHas User ID
true (default)Returns nullReturns nullReturns user ID
falseThrows errorThrows errorReturns 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

ColumnData TypeColumn NameDescription
createdByinteger or textcreated_byUser ID who created the record
modifiedByinteger or textmodified_byUser ID who last modified the record

Validation

The enricher validates the dataType option and throws an error for invalid values:

typescript
// ✅ 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):

typescript
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):

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

typescript
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):

typescript
// 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: false

Schema 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

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

  1. Accepts snake_case input (validates against original schema)
  2. Transforms the data to camelCase at runtime
  3. Validates the transformed data against a camelCase schema

Usage Example

typescript
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

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

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

  1. First validation: Checks that input matches snake_case schema
  2. Transformation: Converts keys from snake_case to camelCase
  3. Second validation: Validates transformed data against camelCase schema
typescript
// 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() and toCamel() utilities from @venizia/ignis-helpers
  • Recursively handles nested objects
  • Preserves array structures
  • Works seamlessly with Zod's other features (refinements, transforms, etc.)

See Also