08 - Types avec les ORMs (Prisma, Drizzle)
Ce que tu vas apprendre
- Comment Prisma généré les types depuis le schema
- Les type helpers de Prisma (
Prisma.UserCreateInput, etc.) - select/include et le narrowing de types
- L'approche Drizzle : le schema comme code TypeScript
- Comparer la type safety de Prisma vs Drizzle
- Typer les résultats de la base de donnees dans les réponses API
Prerequisites
Avoir lu l'article sur le typage d'API REST.
Le monde avant les ORMs types
Il y a quelques annees, on ecrivait ca :
typescriptconst result = await db.query("SELECT * FROM users WHERE id = $1", [userId])
const user = result.rows[0] // type: any
Le résultat est any. Tu peux écrire user.naem sans que TypeScript bronche. Si la table change, rien ne casse a la compilation — tout casse en production.
Les ORMs types ont change la donne. Le schema de la base de donnees devient la source de vérité pour les types TypeScript.
Prisma : types générés depuis le schema
Prisma utilise un fichier .prisma pour définir le schema :
prisma// prisma/schema.prisma
model User {
id String @id @default(uuid())
name String
email String @unique
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
}
enum Role {
USER
ADMIN
EDITOR
}
Apres npx prisma generate, Prisma généré des types TypeScript dans node_modules/.prisma/client. Chaque modèle a un type correspondant :
typescript// Genere automatiquement
type User = {
id: string
name: string
email: string
role: Role
createdAt: Date
updatedAt: Date
}
type Post = {
id: string
title: string
content: string | null
published: boolean
authorId: string
}
type Role = "USER" | "ADMIN" | "EDITOR"
Les champs optionnels (String?) deviennent string | null. Les enums deviennent des union literals. Les relations ne sont pas incluses dans le type de base — elles apparaissent quand tu utilises include.
Les type helpers de Prisma
Prisma généré des types d'input pour chaque opération :
typescriptimport { Prisma } from "@prisma/client"
// Type pour creer un user
type CreateInput = Prisma.UserCreateInput
// {
// name: string
// email: string
// role?: Role
// posts?: Prisma.PostCreateNestedManyWithoutAuthorInput
// }
// Type pour mettre a jour un user
type UpdateInput = Prisma.UserUpdateInput
// {
// name?: string | StringFieldUpdateOperationsInput
// email?: string | StringFieldUpdateOperationsInput
// role?: Role | EnumRoleFieldUpdateOperationsInput
// }
// Type pour filtrer les users
type WhereInput = Prisma.UserWhereInput
// {
// id?: string | StringFilter
// name?: string | StringFilter
// email?: string | StringFilter
// role?: Role | EnumRoleFilter
// AND?: UserWhereInput[]
// OR?: UserWhereInput[]
// NOT?: UserWhereInput[]
// }
Ces types sont générés. Tu ne les ecris pas. Quand tu ajoutes un champ au schema Prisma, les types d'input se mettent à jour apres prisma generate.
select et include : le narrowing
Le type de retour de Prisma change selon les champs que tu selectionnes :
typescript// Sans select — retourne tous les champs scalaires
const user = await prisma.user.findUnique({ where: { id: "123" } })
// type: User | null
// Avec select — retourne seulement les champs specifies
const user = await prisma.user.findUnique({
where: { id: "123" },
select: { name: true, email: true }
})
// type: { name: string; email: string } | null
// Avec include — ajoute les relations
const user = await prisma.user.findUnique({
where: { id: "123" },
include: { posts: true }
})
// type: (User & { posts: Post[] }) | null
Le type de retour est calcule a la compilation. Si tu fais select: { name: true }, le résultat n'a que name. Pas email, pas id. TypeScript le sait.
C'est un des points forts de Prisma : les types des requêtes sont precis. Tu ne recois pas un User complet quand tu as sélectionné deux champs.
Le type helper Prisma.UserGetPayload
Pour extraire le type de retour d'une requête spécifique :
typescriptimport { Prisma } from "@prisma/client"
// Definir le shape de la requete
const userWithPosts = Prisma.validator<Prisma.UserFindUniqueArgs>()({
include: { posts: { where: { published: true } } }
})
// Extraire le type de retour
type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>
// { id: string; name: string; email: string; role: Role; posts: Post[]; ... }
Ce pattern est utile quand tu veux réutiliser le type de retour d'une requête spécifique dans plusieurs endroits (composants, tests, fonctions utilitaires).
Les raw queries typees
Les raw queries perdent le typage par défaut :
typescript// ❌ any[] — pas de type safety
const users = await prisma.$queryRaw`SELECT * FROM users WHERE role = 'ADMIN'`
Prisma permet de typer les raw queries avec un generic :
typescript// ✅ Type explicite
interface AdminUser {
id: string
name: string
email: string
}
const users = await prisma.$queryRaw<AdminUser[]>`
SELECT id, name, email FROM users WHERE role = 'ADMIN'
`
// type: AdminUser[]
Le problème : c'est un cast. Prisma ne vérifié pas que la requête SQL retourne vraiment les champs declares. Si tu oublies un champ dans le SELECT, le type ment. Utilise les raw queries seulement quand l'API Prisma standard ne suffit pas (requêtes complexes, CTEs, fonctions SQL spécifiques).
Drizzle : le schema comme code TypeScript
Drizzle prend l'approche inverse de Prisma. Au lieu d'un fichier .prisma qui généré du TypeScript, le schema est du TypeScript :
typescript// src/db/schema.ts
import { pgTable, uuid, text, boolean, timestamp, pgEnum } from "drizzle-orm/pg-core"
export const roleEnum = pgEnum("role", ["USER", "ADMIN", "EDITOR"])
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: roleEnum("role").default("USER").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
export const posts = pgTable("posts", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
content: text("content"),
published: boolean("published").default(false).notNull(),
authorId: uuid("author_id").references(() => users.id).notNull(),
})
Les types sont inferes directement depuis le schema TypeScript :
typescriptimport { InferSelectModel, InferInsertModel } from "drizzle-orm"
type User = InferSelectModel<typeof users>
// { id: string; name: string; email: string; role: "USER" | "ADMIN" | "EDITOR"; ... }
type NewUser = InferInsertModel<typeof users>
// { id?: string; name: string; email: string; role?: "USER" | "ADMIN" | "EDITOR"; ... }
InferSelectModel donne le type de lecture (tous les champs). InferInsertModel donne le type d'insertion (les champs avec default sont optionnels).
Requetes Drizzle typees
typescriptimport { db } from "./db"
import { users, posts } from "./db/schema"
import { eq, and } from "drizzle-orm"
// Select simple
const allUsers = await db.select().from(users)
// type: User[]
// Select avec filtre
const admins = await db.select().from(users).where(eq(users.role, "ADMIN"))
// type: User[]
// Select partiel
const names = await db.select({ name: users.name, email: users.email }).from(users)
// type: { name: string; email: string }[]
// Join
const usersWithPosts = await db
.select({
userName: users.name,
postTitle: posts.title,
})
.from(users)
.innerJoin(posts, eq(users.id, posts.authorId))
// type: { userName: string; postTitle: string }[]
Chaque requête retourne un type precis. Le select partiel retourne seulement les champs demandes. Les joins combinent les types des tables.
Prisma vs Drizzle : la type safety
| Aspect | Prisma | Drizzle |
|---|---|---|
| Source du schema | Fichier .prisma |
Code TypeScript |
| Génération de types | prisma generate (step manuelle) |
Inference directe (pas de step) |
| select/include | Types precis | Types precis |
| Raw queries | Cast manuel (pas vérifié) | SQL typee avec sql template |
| Relations | include avec types générés |
Joins manuels types |
| Migrations | prisma migrate |
drizzle-kit |
Prisma est plus accessible : le schema .prisma est lisible meme sans connaître TypeScript. Les relations sont declaratives. L'API client (findMany, create, update) est intuitive.
Drizzle est plus proche du SQL : les requêtes ressemblent a du SQL, pas a une API d'ORM. Le schema est du TypeScript natif — pas de langage intermediaire. Pas de step de génération.
Sur paltemps.fr, j'utilise Drizzle. Le fait de ne pas avoir de step generate simplifie le workflow. Et la proximite avec SQL fait que je sais exactement quelle requête est exécutée.
Typer les résultats DB dans les réponses API
Le type de la base de donnees n'est pas toujours le type de la réponse API. Les champs internes (mot de passe, tokens) ne doivent pas sortir :
typescript// Type DB complet
type UserRow = InferSelectModel<typeof users>
// { id: string; name: string; email: string; passwordHash: string; ... }
// Type reponse API — sans les champs sensibles
type UserResponse = Omit<UserRow, "passwordHash" | "resetToken">
// Fonction de transformation
function toUserResponse(user: UserRow): UserResponse {
const { passwordHash, resetToken, ...safe } = user
return safe
}
// Dans le handler
app.get("/api/users/:id", async (req) => {
const user = await db.select().from(users).where(eq(users.id, req.params.id))
if (!user[0]) return Response.json({ error: "Not found" }, { status: 404 })
return Response.json(toUserResponse(user[0]))
})
La fonction toUserResponse fait le mapping entre le type DB et le type API. C'est explicite — pas de champ oublie qui fuit dans la réponse.
Un pattern plus avance avec Zod pour valider la sortie :
typescriptimport { z } from "zod"
const UserResponseSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.enum(["USER", "ADMIN", "EDITOR"]),
createdAt: z.date(),
})
type UserResponse = z.infer<typeof UserResponseSchema>
function toUserResponse(user: UserRow): UserResponse {
return UserResponseSchema.parse(user)
}
UserResponseSchema.parse supprime les champs qui ne sont pas dans le schema. C'est une garantie supplementaire : meme si UserRow a un passwordHash, il ne passe pas le filtre Zod.
Résumé
- Prisma généré les types depuis un schema
.prisma— chaqueprisma generatemet à jour les types - Les type helpers (
Prisma.UserCreateInput,Prisma.UserGetPayload) evitent de redefinir les types manuellement selectetincludechangent le type de retour a la compilation — pas de champs fantomes- Drizzle infere les types depuis du code TypeScript natif — pas de step de génération
- Le type DB et le type API sont différents — utilise
Omitou Zod pour filtrer les champs sensibles
Article précédent : 07 - Types avec React
Article suivant : 09 - Tests et TypeScript