Type Safety Overview
Automatic Type Inference
Query result types auto-inferredCompile-time validation
Column Autocomplete
IDE autocomplete supportPrevent typos
JOIN Type Inference
Auto-expanded types after joinLEFT JOIN nullable
Raw Function Types
Raw SQL is also type-safeExplicit return types
Basic Type Inference
SELECT Type Inference
Copy
const users = await db
.table("users")
.select({
id: "id", // number
name: "username", // string
email: "email", // string
isActive: "is_active", // boolean
createdAt: "created_at", // Date
});
// Types are automatically inferred
const first = users[0];
first.id; // number
first.name; // string
first.email; // string
first.isActive; // boolean
first.createdAt; // Date
// ❌ Compile error
first.nonExistent; // Error: Property 'nonExistent' does not exist
WHERE Type Validation
Copy
// ✅ Correct types
await db
.table("users")
.where("id", 1) // number
.where("username", "john") // string
.where("is_active", true); // boolean
// ❌ Compile error
await db
.table("users")
.where("id", "not-a-number"); // Error: Type 'string' is not assignable to type 'number'
JOIN Type Extension
INNER JOIN
Copy
const results = await db
.table("employees")
.join("users", "employees.user_id", "users.id")
.select({
employeeId: "employees.id", // number
userName: "users.username", // string
});
// After join, columns from both tables are available
results[0].employeeId; // number
results[0].userName; // string
LEFT JOIN - nullable Types
Copy
const results = await db
.table("employees")
.leftJoin("departments", "employees.department_id", "departments.id")
.select({
employeeId: "employees.id", // number
departmentName: "departments.name", // string | null (LEFT JOIN)
});
// Columns from LEFT JOIN are nullable
const first = results[0];
first.employeeId; // number
first.departmentName; // string | null
// Null check required
if (first.departmentName) {
console.log(first.departmentName.toUpperCase()); // ✅ OK
}
LEFT JOIN type safety:
- INNER JOIN: Value always exists →
T - LEFT JOIN: Value may not exist →
T | null
Column Autocomplete
IDE Autocomplete
Copy
await db
.table("users")
.select({
id: "users.", // ← IDE provides autocomplete
// users.id
// users.username
// users.email
// ...
});
Prevent Invalid Columns
Copy
// ❌ Compile error
await db
.table("users")
.select({
id: "users.nonexistent", // Error: Column does not exist
});
Raw Function Type Safety
Explicit Type Specification
Copy
const results = await db
.table("users")
.select({
id: "id",
// Each Raw function specifies return type
fullName: Puri.rawString("CONCAT(first_name, last_name)"), // string
age: Puri.rawNumber("EXTRACT(YEAR FROM AGE(birth_date))"), // number
isAdmin: Puri.rawBoolean("role = 'admin'"), // boolean
tags: Puri.rawStringArray("string_to_array(tags, ',')"), // string[]
});
// Types are accurately inferred
results[0].fullName; // string
results[0].age; // number
results[0].isAdmin; // boolean
results[0].tags; // string[]
Aggregate Function Types
Copy
const stats = await db
.table("employees")
.select({
departmentId: "department_id", // number
count: Puri.count("id"), // number
avgSalary: Puri.avg("salary"), // number
maxSalary: Puri.max("salary"), // number
})
.groupBy("department_id");
stats[0].count; // number
stats[0].avgSalary; // number
first() Types
first() returns a single result or undefined.
Copy
const user = await db
.table("users")
.select({ id: "id", name: "username" })
.where("id", 1)
.first();
// Type: { id: number; name: string; } | undefined
if (user) {
// ✅ Safe to use after null check
console.log(user.name.toUpperCase());
} else {
console.log("User not found");
}
// ❌ Compile error (using without null check)
console.log(user.name); // Error: Object is possibly 'undefined'
pluck() Types
Copy
// Array of single column
const ids = await db
.table("users")
.where("role", "admin")
.pluck("id");
// Type: number[]
ids[0]; // number
const names = await db
.table("users")
.pluck("username");
// Type: string[]
names[0]; // string
Type-safe Parameters
Params Type Definition
Copy
// user.types.ts
export const UserListParams = z.object({
role: z.enum(["admin", "normal"]).optional(),
search: z.string().optional(),
page: z.number().int().min(1),
pageSize: z.number().int().min(1).max(100),
});
export type UserListParams = z.infer<typeof UserListParams>;
Using in API Methods
Copy
async findUsers(params: UserListParams) {
let query = this.getPuri("r")
.table("users")
.select({
id: "id",
name: "username",
role: "role",
});
// Type-safe condition additions
if (params.role) {
query = query.where("role", params.role);
}
if (params.search) {
query = query.where("username", "like", `%${params.search}%`);
}
const users = await query
.orderBy("created_at", "desc")
.limit(params.pageSize)
.offset((params.page - 1) * params.pageSize);
return users;
}
Subquery Types
Copy
// Subquery result types are also safe
const adminUsers = db
.table("users")
.where("role", "admin")
.select({
id: "id",
name: "username",
});
// adminUsers type: Puri<Schema, { users: User }, { id: number; name: string; }>
const results = await db
.table({ admins: adminUsers })
.select({
adminId: "admins.id", // number
adminName: "admins.name", // string
});
Practical Examples
Type-safe Search Filter
Copy
interface SearchFilters {
departmentId?: number;
minSalary?: number;
maxSalary?: number;
isActive?: boolean;
search?: string;
}
async searchEmployees(filters: SearchFilters) {
let query = this.getPuri("r")
.table("employees")
.join("users", "employees.user_id", "users.id")
.leftJoin("departments", "employees.department_id", "departments.id")
.select({
employeeId: "employees.id",
userName: "users.username",
salary: "employees.salary",
departmentName: "departments.name", // string | null
isActive: "users.is_active",
});
// All conditions are type-safe
if (filters.departmentId !== undefined) {
query = query.where("employees.department_id", filters.departmentId);
}
if (filters.minSalary !== undefined) {
query = query.where("employees.salary", ">=", filters.minSalary);
}
if (filters.maxSalary !== undefined) {
query = query.where("employees.salary", "<=", filters.maxSalary);
}
if (filters.isActive !== undefined) {
query = query.where("users.is_active", filters.isActive);
}
if (filters.search) {
query = query.where("users.username", "like", `%${filters.search}%`);
}
return await query.orderBy("employees.id", "asc");
}
Using Generics
Copy
interface PaginationParams {
page: number;
pageSize: number;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
async paginate<T>(
query: Puri<any, any, T>,
params: PaginationParams
): Promise<PaginatedResult<T>> {
const { page, pageSize } = params;
// Total count
const countQuery = query.clone();
const total = await countQuery.count();
// Page data
const data = await query
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
data,
total,
page,
pageSize,
};
}
// Usage
const userQuery = db.table("users").select({ id: "id", name: "username" });
const result = await this.paginate(userQuery, { page: 1, pageSize: 20 });
// result.data type: { id: number; name: string; }[]
Type Guards
Narrowing Result Types
Copy
interface AdminUser {
id: number;
username: string;
role: "admin";
permissions: string[];
}
interface NormalUser {
id: number;
username: string;
role: "normal";
}
type User = AdminUser | NormalUser;
function isAdmin(user: User): user is AdminUser {
return user.role === "admin";
}
const user = await db
.table("users")
.select({
id: "id",
username: "username",
role: "role",
})
.where("id", 1)
.first();
if (user && isAdmin(user)) {
// TypeScript recognizes user as AdminUser
console.log(user.permissions); // ✅ OK
}