TypeScript en pratique - 08 - Types avec les ORMs (Prisma, Drizzle)

Comment Prisma et Drizzle generent et inferent les types, et comment les utiliser dans une API type-safe.

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 — chaque prisma generate met à jour les types
  • Les type helpers (Prisma.UserCreateInput, Prisma.UserGetPayload) evitent de redefinir les types manuellement
  • select et include changent 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 Omit ou Zod pour filtrer les champs sensibles

Article précédent : 07 - Types avec React

Article suivant : 09 - Tests et TypeScript

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.