This document explains the advanced TypeScript mechanics powering drizzle-castor. We leverage complex generics, recursive mapped types, and conditional types to ensure end-to-end type safety—from schema definition down to the hydrated query results.
The foundation of our type system relies on Drizzle ORM's native type inference. We extract the exact shapes of your tables without requiring you to write redundant TypeScript interfaces.
Using internal helpers (InferEntity and InferModel), the library infers the Select and Insert types directly from the Drizzle table objects passed to the schema builder.
// Drizzle Schema
export const usersTable = sqliteTable("users", {
id: int("id").primaryKey(),
name: text("name").notNull(),
});
// drizzle-castor implicitly infers this as:
type UserEntity = {
id: number;
name: string;
};One of the core features of drizzle-castor is querying via dot-notation for both SQL relationships and nested JSON columns. To make this type-safe, we use the recursive FlattenPaths generic.
The FlattenPaths generic recursively traverses the inferred entity type up to a defined depth (default is 5). It generates a union of all possible valid string paths, natively supporting array indices.
type User = {
id: number;
persona: {
skills: string[];
role: string;
};
};
// Generates: "id" | "persona" | "persona.skills" | "persona.skills.0" | "persona.role"
type ValidUserPaths = ValidPath<User>;When you write a filter, TypeScript enforces that the string actually exists within the graph:
userRepo.searchOne({
filter: {
"persona.role": { $eq: "admin" }, // ✅ Valid
"persona.age": { $eq: 25 }, // ❌ TypeScript Error: property does not exist
}
});Not all SQL operators are valid for all data types. For example, using $like on an integer should be prevented at compile-time.
The FieldOperators type uses conditional types to inspect the leaf value of your requested path (using ValueAt<T, Path>). It dynamically constructs the allowed operator payload.
// If targeting a String path:
filter: {
"name": { $like: "%John%", $ilike: "john" } // ✅ Allowed by StringOps
}
// If targeting a Number path:
filter: {
"id": { $gt: 10, $lte: 50 }, // ✅ Allowed by OrderableOps
"id": { $like: "%10%" } // ❌ TypeScript Error: $like does not exist on numeric ops
}
// If targeting an Array path:
filter: {
"persona.skills": { $arrayContains: ["TypeScript"] } // ✅ Allowed by ArrayContainmentOps
}When fetching data, you rarely want the entire object graph, especially when utilizing RBAC data trimming. You use the projection array to select specific fields.
We ensure the return type of your repository methods accurately reflects this selection using the DbQueryResult and DeepPick generics.
const user = await userRepo.searchOne({
projection: ["id", "persona.skills.0"]
});
// TypeScript infers `user` exactly as:
// {
// id: number | null;
// persona: {
// skills: string[]; // Truncated to just what you asked for
// }
// }If you omit the projection array, the repository safely defaults to returning the full inferred TEntity type.
The createSchemaBuilder acts as the master type registry. By passing your tables as a const tuple, TypeScript infers the exact table names and their specific relationship configurations.
const builder = createSchemaBuilder(db, [usersTable, postsTable] as const);
// TTableName is strictly typed to "users" | "posts"
const userRepo = builder.build().repoFactory("users");// ...
Because userRepo is instantiated with the literal "users", all repository methods (createOne, searchMany, updateMany) are permanently bound to the UserEntity type and its valid relational paths, securing the entire API surface from end to end.
We use a strictly typed event bus via mitt. Every event payload is defined to ensure that subscribers can safely access metadata, performance metrics, and mutation records.
type TelemetryEvents = {
execution: ExecutionEvent; // Latency, status, traceId
security: SecurityEvent; // Audit logs for field trimming
error: ErrorEvent; // Global exception details
"soft-deleted": MutationEvent; // Records affected by soft-delete
"restored": MutationEvent; // Records affected by restore
"hard-deleted": MutationEvent; // Records affected by permanent delete
};Subscribing is type-safe:
builder.on('execution', (ev) => {
ev.duration; // inferred as number
ev.action; // inferred as "read" | "create" | ...
});The policy system supports both static maps and dynamic callbacks. TypeScript ensures that your profile names are consistent throughout the application.
// Define valid profiles once
builder.profiles(['admin', 'guest'] as const);
// Policy definitions are now checked against those profiles
builder.policies('users', {
admin: { allowedActions: "*" }, // Valid
super: { ... } // Error: "super" is not in ['admin', 'guest']
});The UnifiedPolicyConfig supports:
- Declarative:
Record<TProfile, PolicyDefinition> - Imperative:
(ctx) => PolicyDefinition | Promise<PolicyDefinition> - Global:
(ctx, table, profiles) => PolicyDefinition
The internal logger configuration allows for Quarkus-style pattern strings and log levels.
type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL" | "OFF";
interface LoggerConfig {
level?: LogLevel;
pattern?: string; // e.g., "%d %p [%c] (%t) %s"
}