Gérer les erreurs
Une erreur est un signal, pas un incident à cacher : résultat attendu ou vraie erreur (la connexion), TRPCError et ses codes, l’unicité confiée à la base (P2002 → CONFLICT), une règle métier qui lève FORBIDDEN, et ce que le navigateur reçoit d’une erreur imprévue (errorFormatter, onError).
Précédemment. Zod garde l’entrée : une donnée invalide est refusée avant toute logique, et le service travaille sur des données fiables.
Nous avons maintenant un système propre.
Une entrée validée, un contexte fiable, une sécurité centralisée, une logique déléguée.
Mais ce système est encore décrit dans son "happy path".
Et un backend réel ne vit pas uniquement dans le cas favorable.
🧠 La vraie question#
Que se passe-t-il quand quelque chose échoue ?
Sans cadre clair, les dérives sont prévisibles.
Des try/catch apparaissent partout.
Des return { success: false } remplacent de vraies erreurs.
Chaque couche formate les messages à sa façon.
Le client reçoit des réponses incohérentes selon où l’erreur s’est produite.
💥 Le flux devient illisible.
Pas parce que ça casse — parce que ça ne casse plus de façon prévisible. Une erreur identifiable est une ressource, pas un problème. Un échec silencieux, ou masqué, ou déformé, est bien pire : il se propage sans être remarqué, jusqu’à provoquer un effet inattendu loin de sa cause.
🔥 Le principe fondamental#
Une erreur doit être :
- lancée au bon endroit, par la couche qui sait pourquoi elle échoue
- expressive, avec un code et un message exploitables
- propagée sans être déformée
Et surtout : elle ne doit pas être transformée en pseudo-succès.
1) Ne pas transformer une erreur en résultat#
Anti-pattern : masquer l'erreur
try { const user = await service.createUser(ctx, input) return { success: true, user }} catch (e) { return { success: false, message: "Une erreur est survenue" }}En retournant { success: false }, nous perdons :
- la cause réelle de l’erreur
- le code HTTP approprié
- la possibilité pour le client de distinguer une erreur métier d’une erreur réseau
👉 Le client ne peut plus réagir intelligemment.
👉 Il sait juste que "ça a échoué".
Et "ça a échoué" n’est pas une information exploitable. Le client ne peut pas distinguer "l’utilisateur n’a pas les droits" de "le serveur est tombé" de "les données sont invalides". Les trois méritent pourtant des réactions UX très différentes.
2) Le projet avait ce motif, à un endroit précis#
La connexion renvoie un résultat plutôt que de lever une erreur quand le mot de passe est faux : { success: false, error: "INVALID_CREDENTIALS" }. C’est un choix délibéré, et il est juste. Un mauvais mot de passe n’est pas un incident : il fait partie du fonctionnement normal du formulaire, qui choisit le message à afficher selon le code reçu.
Mais la première version allait plus loin. Toute autre erreur — la base injoignable, un bug dans le service — devenait elle aussi un résultat : { success: false, error: "LOGIN_FAILED" }. Le formulaire affichait « Connexion impossible », le serveur ne journalisait rien, et la cause disparaissait. Exactement le contre-exemple ci-dessus, en plus discret.
La procédure distingue désormais les deux cas :
login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema) .mutation(async ({ input }) => { const { email, password } = input; try { await loginService(email, password); return { success: true as const, }; } catch (error) { // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte, // est journalisée par la route /api/trpc, et le formulaire affiche son message générique. if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") { return { success: false as const, error: "INVALID_CREDENTIALS" as const, }; } // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique. if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") { return { success: false as const, error: "TOO_MANY_ATTEMPTS" as const, }; } throw error; } }),Le résultat attendu reste un résultat. L’imprévu redevient une erreur : il remonte, la route /api/trpc le journalise (section 6), et le formulaire, qui intercepte déjà les exceptions, affiche son message générique. Rien n’a changé pour l’utilisateur ; tout a changé pour celui qui devra comprendre une panne.
La frontière tient en une question : le code qui appelle sait-il quoi faire de ce cas ? Si oui (un mauvais mot de passe : afficher un message), c’est un résultat, et son type le décrit. Si non (la base ne répond pas), c’est une erreur, et elle doit rester visible.
3) Laisser l’erreur exister — avec TRPCError#
Dans ce projet, les erreurs sont des signaux explicites.
Voyons comment elles vivent dans un routeur réel, celui qui crée les membres :
create: protectedProcedure .use(isAdmin) .input( z.object({ // Même règle que la connexion : nettoyée, en minuscules (sinon le membre ne retrouverait // pas son compte en tapant son adresse autrement). email: emailSchema, /** Un ou plusieurs groupes (au moins un). */ groupIds: z.array(z.string().min(1)).min(1, "Choisissez au moins un groupe."), /** Majorité et, pour un mineur, date de naissance et parent référent (tous obligatoires). */ legal: legalInfoInput, }), ) .mutation(async ({ ctx, input }) => { const groupIds = [...new Set(input.groupIds)]; const found = await ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }); if (found !== groupIds.length) { throw new TRPCError({ code: "BAD_REQUEST", message: "Groupe inconnu : rechargez la page." }); } // Mot de passe généré côté serveur (jamais saisi par l'admin), robuste par // construction, puis envoyé au SEUL membre (voir welcomeEmailWithPassword.ts). // isFirstLogin=true (défaut Prisma) : le site lui propose de compléter son profil. const password = generateStrongPassword(); const hash = await hashPassword(password); if (input.legal.legalAge === "UNKNOWN") { throw new TRPCError({ code: "BAD_REQUEST", message: "Indiquez si le membre est majeur ou mineur." }); } const legal = legalInfoData(input.legal, input.email, { requireComplete: true }); // L'adresse est unique en base (@unique). Plutôt que de vérifier avant d'écrire (deux // créations simultanées passeraient toutes les deux la vérification), on écrit, et on // traduit le refus de la base. let user; try { user = await ctx.prisma.user.create({ data: { email: input.email, password: hash, ...legal, memberGroupMemberships: { create: groupIds.map((id) => ({ group: { connect: { id } }, access: "EDITOR" as const, })), }, }, omit: { password: true }, }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { throw new TRPCError({ code: "CONFLICT", message: "Un membre utilise déjà cette adresse e-mail." }); } throw err; } // E-mail de bienvenue au membre, puis avis SANS mot de passe à la boîte du club. // Un échec d'envoi n'annule pas la création : il est journalisé et signalé au club. // Compte de démonstration : son adresse ne peut rien recevoir, pas d'e-mail. if (!isDemoEmail(input.email)) { let sendError: string | null = null; try { await sendWelcomeEmail({ to: input.email, password }); } catch (err) { sendError = err instanceof Error ? err.message : String(err); console.error("[user.create] e-mail de bienvenue non envoyé :", sendError); } const [groups, admin] = await Promise.all([ ctx.prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { name: true } }), ctx.prisma.user.findUnique({ where: { id: ctx.sessionClient.user.id }, select: { firstName: true, lastName: true, email: true }, }), ]); const adminName = admin ? [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim() || admin.email : null; await sendWelcomeNotice({ kind: sendError ? "send-failed" : "sent", memberEmail: input.email, groupNames: groups.map((g) => g.name), createdBy: adminName, detail: sendError, }).catch((err) => console.error("[user.create] avis au club non envoyé :", err instanceof Error ? err.message : err), ); } return { success: true,Lecture guidée — anatomie d'une erreur métier#
De l’écriture à l’erreur propagée
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { Prisma } from "@prisma/client";import { router, protectedProcedure, publicProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { z } from "zod";import generateStrongPassword from "@backend/lib/security/generatePassword";import { hashPassword } from "@backend/lib/auth/password";import { sendWelcomeEmail, sendWelcomeNotice } from "@backend/email/templates/welcomeEmailWithPassword";import { isDemoEmail } from "@backend/lib/demoAccounts";import { TRPCError } from "@trpc/server";import type { UserProfile } from "@contracts/users/user-profile.types";import type { ImageConsentProofValue } from "@contracts/legal/imageConsent";import { legalInfoInput, legalInfoData } from "@backend/modules/users/legalInfo";import { emailSchema } from "@contracts/auth/auth.schema";import { syncPageMediaReferences } from "@backend/modules/media/services/syncPageMediaReferences.service";import { pageContentSchemaV1 } from "@contracts/page";import { assertBioMediaAllowed, isInstructorUser } from "@backend/modules/users/instructorBio.service";import { removeAvatarFile } from "@backend/modules/avatar/avatar.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { assertOwnAvatars } from "@backend/modules/media/services/assertOwnAvatars.service";import { byEffectiveDesc } from "@backend/modules/imageConsent/formOrder";/** Les champs du profil, et eux seuls : ni l'empreinte du mot de passe, ni les données légales. */const userProfileSelect = { id: true, email: true, firstName: true, lastName: true, pseudo: true, avatar: true, aboutMe: true, phone: true, birthDate: true, isFirstLogin: true,} satisfies Prisma.UserSelect;type UserProfileRow = Prisma.UserGetPayload<{ select: typeof userProfileSelect }>;/** * Du membre lu en base au contrat partagé avec le front : la date devient « AAAA-MM-JJ ». * `satisfies` vérifie la conformité à UserProfile sans élargir le type : le front garde * `id: string` (sûr), là où le contrat, plus large, admettrait `null`. */function toUserProfile(user: UserProfileRow) { return { ...user, birthDate: user.birthDate ? user.birthDate.toISOString().split("T")[0] : null, } satisfies UserProfile;}export const userRouter = router({ getAll: protectedProcedure .use(isAdmin) .query(async ({ ctx }) => { // Jamais l'empreinte du mot de passe vers le navigateur. const users = await ctx.prisma.user.findMany({ orderBy: { id: "asc" }, omit: { password: true }, include: { memberGroupMemberships: { select: { group: { select: { id: true, name: true, isAdminGroup: true } } }, }, // Droit à l'image : voie de la version la plus récente (par date des droits), et demande en // attente du parent. imageConsentForms: { select: { source: true, signedAt: true, createdAt: true } }, imageConsentRequest: { select: { expiresAt: true } }, }, }); const now = new Date(); return users.map(({ memberGroupMemberships, imageConsentForms, imageConsentRequest, ...u }) => ({ ...u, isDemo: isDemoEmail(u.email), consentProof: (imageConsentForms.sort(byEffectiveDesc)[0]?.source ?? (imageConsentRequest?.expiresAt && imageConsentRequest.expiresAt > now ? "PENDING_GUARDIAN" : "NONE")) satisfies ImageConsentProofValue, // Groupes du membre : Administrateurs d'abord, puis ordre alphabétique. groups: memberGroupMemberships .map((m) => m.group) .sort((a, b) => Number(b.isAdminGroup) - Number(a.isAdminGroup) || a.name.localeCompare(b.name, "fr")), })); }), getById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), getByEmail: protectedProcedure .use(isAdmin) .input(z.object({ email: emailSchema })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), create: protectedProcedure .use(isAdmin) .input( z.object({ // Même règle que la connexion : nettoyée, en minuscules (sinon le membre ne retrouverait // pas son compte en tapant son adresse autrement). email: emailSchema, /** Un ou plusieurs groupes (au moins un). */ groupIds: z.array(z.string().min(1)).min(1, "Choisissez au moins un groupe."), /** Majorité et, pour un mineur, date de naissance et parent référent (tous obligatoires). */ legal: legalInfoInput, }), ) .mutation(async ({ ctx, input }) => { const groupIds = [...new Set(input.groupIds)]; const found = await ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }); if (found !== groupIds.length) { throw new TRPCError({ code: "BAD_REQUEST", message: "Groupe inconnu : rechargez la page." }); } // Mot de passe généré côté serveur (jamais saisi par l'admin), robuste par // construction, puis envoyé au SEUL membre (voir welcomeEmailWithPassword.ts). // isFirstLogin=true (défaut Prisma) : le site lui propose de compléter son profil. const password = generateStrongPassword(); const hash = await hashPassword(password); if (input.legal.legalAge === "UNKNOWN") { throw new TRPCError({ code: "BAD_REQUEST", message: "Indiquez si le membre est majeur ou mineur." }); } const legal = legalInfoData(input.legal, input.email, { requireComplete: true }); // L'adresse est unique en base (@unique). Plutôt que de vérifier avant d'écrire (deux // créations simultanées passeraient toutes les deux la vérification), on écrit, et on // traduit le refus de la base. let user; try { user = await ctx.prisma.user.create({ data: { email: input.email, password: hash, ...legal, memberGroupMemberships: { create: groupIds.map((id) => ({ group: { connect: { id } }, access: "EDITOR" as const, })), }, }, omit: { password: true }, }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { throw new TRPCError({ code: "CONFLICT", message: "Un membre utilise déjà cette adresse e-mail." }); } throw err; } // E-mail de bienvenue au membre, puis avis SANS mot de passe à la boîte du club. // Un échec d'envoi n'annule pas la création : il est journalisé et signalé au club. // Compte de démonstration : son adresse ne peut rien recevoir, pas d'e-mail. if (!isDemoEmail(input.email)) { let sendError: string | null = null; try { await sendWelcomeEmail({ to: input.email, password }); } catch (err) { sendError = err instanceof Error ? err.message : String(err); console.error("[user.create] e-mail de bienvenue non envoyé :", sendError); } const [groups, admin] = await Promise.all([ ctx.prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { name: true } }), ctx.prisma.user.findUnique({ where: { id: ctx.sessionClient.user.id }, select: { firstName: true, lastName: true, email: true }, }), ]); const adminName = admin ? [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim() || admin.email : null; await sendWelcomeNotice({ kind: sendError ? "send-failed" : "sent", memberEmail: input.email, groupNames: groups.map((g) => g.name), createdBy: adminName, detail: sendError, }).catch((err) => console.error("[user.create] avis au club non envoyé :", err instanceof Error ? err.message : err), ); } return { success: true, user, }; }), /** * Majorité, date de naissance et parent référent (admins). Un mineur peut être enregistré avant * que tout soit connu (tri des comptes existants) : l'envoi au parent reste bloqué tant qu'il * manque quelque chose. Majeur : les coordonnées du parent sont effacées. */ setLegalInfo: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), legal: legalInfoInput })) .mutation(async ({ ctx, input }) => { const u = await ctx.prisma.user.findUnique({ where: { id: input.userId }, select: { email: true } }); if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." }); await ctx.prisma.user.update({ where: { id: input.userId }, data: legalInfoData(input.legal, u.email, { requireComplete: false }), select: { id: true }, }); return { ok: true }; }), /** Droit à l'image (admins) : autorisation recueillie par le club pour ce membre. */ setImageConsent: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), consent: z.enum(["UNKNOWN", "GRANTED", "REFUSED"]) })) .mutation(async ({ ctx, input }) => { await ctx.prisma.user.update({ where: { id: input.userId }, data: { imageConsent: input.consent, imageConsentAt: new Date() }, select: { id: true }, }); return { ok: true }; }), delete: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte, // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur). // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie. if (input.id === ctx.sessionClient.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas supprimer votre propre compte." }); } const avatar = (await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { avatar: true } }))?.avatar; const deleted = await ctx.prisma.$transaction(async (tx) => { // Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne). await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } }); // `pageId` n'a pas de clé étrangère : sans ce nettoyage, les liens de // la bio bloqueraient pour toujours la suppression de ses images. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: input.id, newContent: null, }); // Seul l'identifiant repart vers le navigateur : la ligne complète contient l'empreinte // du mot de passe. return tx.user.delete({ where: { id: input.id }, select: { id: true }, }); }); // Son portrait part avec le compte : un avatar n'est rattaché qu'à lui (aucune ligne // MediaAsset ne le retient), il resterait sinon dans le stockage sans que rien ne l'atteigne. // Après la transaction : un fichier ne se restaure pas si la base annule. if (avatar) await removeAvatarFile(avatar); return deleted; }), getCurrentUserProfile: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), getProfileById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), /** * L'utilisateur courant est-il titulaire, et quelle est sa bio ? * * Alimente l'éditeur du profil : la section n'apparaît que pour un * titulaire, et se pré-remplit avec ce qu'il a déjà écrit. Le critère de * titulaire est le MEME OR que `getInstructors` / `listPublicInstructors`. */ getMyInstructorState: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const [user, isInstructor] = await Promise.all([ ctx.prisma.user.findUnique({ where: { id: userId }, select: { instructorBio: true, instructorOrder: true }, }), isInstructorUser(ctx.prisma, userId), ]); return { isInstructor, bio: user?.instructorBio ?? null, order: user?.instructorOrder ?? null, // Où le sélecteur de médias s'ouvre : toute la bibliothèque pour un administrateur, son // espace personnel pour un autre membre (le seul dont il puisse poser les fichiers). mediaRoot: ctx.sessionClient.user.isAdmin ? ctx.appRoot : await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId }), }; }), /** * Un instructeur enregistre SA PROPRE présentation. * * La cible est toujours `ctx.sessionClient.user.id`, jamais un id reçu en * entrée : la garde « c'est mon profil » est structurelle. Aucune * permission d'administration n'entre en jeu — on n'édite que soi. * * `bio` est un document du constructeur de pages (validé par son schéma), ou `null` * pour retirer sa présentation. L'ordre est optionnel. */ saveMyInstructorBio: protectedProcedure .input( z.object({ // Le même contrat que tout contenu du constructeur de pages : un document invalide est // refusé à l'entrée, au lieu d'être enregistré tel quel. bio: pageContentSchemaV1.nullable(), order: z.number().int().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { assertOwnAvatars(ctx.sessionClient.user.id, input.bio); const userId = ctx.sessionClient.user.id; // Seul un titulaire a une présentation publique. Un autre membre pourrait sinon référencer des // fichiers, et les rendre servis au public, sans qu'aucune page ne les montre. if (!(await isInstructorUser(ctx.prisma, userId))) { throw new TRPCError({ code: "FORBIDDEN", message: "La présentation est réservée aux instructeurs." }); } if (!ctx.sessionClient.user.isAdmin) { // L'ordre de la page publique est une décision du club : un instructeur ne s'y place pas // lui-même en tête. if (input.order !== undefined) { throw new TRPCError({ code: "FORBIDDEN", message: "L'ordre d'affichage est fixé par un administrateur." }); } if (input.bio !== null) await assertBioMediaAllowed(ctx.prisma, userId, input.bio); } await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { instructorBio: input.bio === null ? Prisma.DbNull : (input.bio as Prisma.InputJsonValue), ...(input.order !== undefined ? { instructorOrder: input.order } : {}), }, }); // Déclare les fichiers de la bibliothèque utilisés par la bio : sans // ce lien, ils ne sont ni protégés, ni servis aux visiteurs. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: userId, newContent: input.bio, }); }); return { success: true }; }), /** * La page publique des instructeurs titulaires. * * PUBLIQUE : elle alimente une page ouverte à tous. Deux filtres — * 1. titulaire : le MEME critere que `getInstructors` (rattachement a * une discipline, un cours ou un stage). Reutilise, pas redefini. * 2. presente : `instructorBio` non nul, sinon on afficherait une carte * vide pour un titulaire qui ne s'est pas encore decrit. * * Tri par `instructorOrder` (les nuls en dernier), puis par nom. */ listPublicInstructors: publicProcedure.query(async ({ ctx }) => { const rows = await ctx.prisma.user.findMany({ where: { AND: [ { instructorBio: { not: Prisma.DbNull } }, { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, instructorBio: true, instructorOrder: true, }, orderBy: [ { instructorOrder: { sort: "asc", nulls: "last" } }, { lastName: "asc" }, { firstName: "asc" }, ], }); return rows; }), /** * Vivier d'AFFECTATION du select instructeur (≠ « titulaires » réels). * Résout l'œuf-et-la-poule : membres du groupe ADMIN (le staff) OU déjà * rattachés (pour ne perdre aucun instructeur existant, même non-admin). * `getInstructors` / `listPublicInstructors` restent la vérité publique. */ getAssignableInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { memberGroupMemberships: { some: { group: { isAdminGroup: true } } } }, { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, // Organisateur d'un évènement : sans cette ligne, un organisateur qui n'est ni admin ni // instructeur disparaît du sélecteur, et le formulaire d'édition affiche « — Choisir — ». { eventsAsOrganizer: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Liste tous les users qui sont **factuellement** instructeurs — c'est-à-dire * qui ont au moins une relation d'animation dans le club : ils animent * une discipline, un cours, ou un stage (en tant qu'animateur principal * ou secondaire). * * Le club n'a pas de rôle "instructor" nominal en schéma ; cette * définition factuelle permet de peupler les sélecteurs des forms admin * (Course, Stage, etc.) sans dépendre d'un signal qui n'existe pas. * * Si un jour tu veux désigner un nouvel instructeur qui n'anime encore * rien, il faudra introduire un autre signal (rôle dédié ou flag * `isInstructor` sur User) — auquel cas cette query devra évoluer. * * Sélection minimale (id + nom) suffisante pour l'affichage en dropdown. * Tri par lastName puis firstName pour l'UX du select. */ getInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Adresse du club d'un membre (alias @<domaine du club>), saisie par un administrateur. * Le domaine est celui de l'adresse de contact des Réglages du site (contact@akfc.fr par * défaut) : on n'attribue pas d'adresse d'un autre domaine. Chaîne vide → adresse retirée. */ setClubEmail: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string().min(1), clubEmail: z.string().trim().max(254) })) .mutation(async ({ ctx, input }) => { const value = input.clubEmail.toLowerCase(); if (value !== "") { if (!z.string().email().safeParse(value).success) { throw new TRPCError({ code: "BAD_REQUEST", message: "Adresse e-mail invalide." }); } const settings = await ctx.prisma.siteSettings.findUnique({ where: { id: "site" }, select: { contactEmail: true } }); const domain = (settings?.contactEmail ?? "contact@akfc.fr").split("@")[1]?.toLowerCase() ?? "akfc.fr"; if (!value.endsWith(`@${domain}`)) { throw new TRPCError({ code: "BAD_REQUEST", message: `L'adresse du club doit se terminer par @${domain}.` }); } } await ctx.prisma.user.update({ where: { id: input.id }, data: { clubEmail: value || null } }); return { clubEmail: value || null }; }), /** * Les avatars qu'un bloc media-text ou float-text peut montrer (« utiliser l'avatar de X »). * * Un membre ne pose que SON avatar, administrateurs compris (`assertOwnAvatars`, vérifié par * chaque enregistrement de contenu) : la liste ne contient donc que lui. Elle reste une liste * pour que le sélecteur n'ait rien à changer. Inclut un membre SANS avatar (avatar `null`) : * l'UI affiche un placeholder. `avatar` est la clé de stockage brute ; l'URL d'affichage est construite côté * client comme pour l'avatar du header. */ listAvatarCandidates: protectedProcedure.query(async ({ ctx }) => { const self = ctx.sessionClient.user.id; return ctx.prisma.user.findMany({ where: { id: self }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }),});Une erreur n’est pas un résultat alternatif.
C’est un signal qui porte une sémantique précise.
4) Les codes d’erreur tRPC ne sont pas arbitraires#
Codes d’erreur et leur sémantique
Code | HTTP | Signification | Lancé par |
|---|---|---|---|
BAD_REQUEST | 400 | Entrée invalide, ou référence inexistante | Zod (automatiquement), la procédure, un service |
UNAUTHORIZED | 401 | Aucune session | isAuthed (protectedProcedure), isAdmin |
FORBIDDEN | 403 | Session valide, droit insuffisant | isAdmin, une garde d’espace, une règle métier |
NOT_FOUND | 404 | L’élément visé n’existe pas (ou n’est pas visible) | la procédure |
CONFLICT | 409 | Doublon, ou état incompatible | la procédure, souvent en traduisant P2002 |
INTERNAL_SERVER_ERROR | 500 | Tout ce qui n’est pas une TRPCError | tRPC lui-même, qui emballe l’erreur imprévue |
Showing entries1to6entries
Chaque couche a ses codes.
Zod lève BAD_REQUEST automatiquement si .input() échoue.
isAuthed lève UNAUTHORIZED.
isAdmin lève FORBIDDEN.
La procédure, ou le service qu’elle appelle, lève NOT_FOUND, CONFLICT ou BAD_REQUEST selon la règle violée.
Et tout ce que personne n’a levé volontairement devient INTERNAL_SERVER_ERROR.
👉 Le flux d’erreur suit exactement le flux d’exécution.
Le message accompagne l’erreur jusqu’au client, où le composant peut l’afficher. Les messages des erreurs voulues sont donc rédigés pour l’utilisateur, en français : « Un membre utilise déjà cette adresse e-mail. », « Action réservée aux administrateurs. ».
🔭 Pour aller plus loin — pourquoi ces codes sont des codes HTTP#
Les codes que tRPC utilise (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, etc.) correspondent à des codes HTTP standardisés. Ce n’est pas une coïncidence.
tRPC transporte ses erreurs via HTTP. Quand le serveur lève TRPCError({ code: "NOT_FOUND" }), la réponse HTTP est bien un 404. Cela signifie que les outils standards du web — devtools, logs de proxy, outils de monitoring — voient les erreurs comme des codes HTTP conventionnels.
C’est une bonne pratique : s’intégrer aux conventions existantes plutôt que réinventer un vocabulaire propriétaire. Un développeur qui connaît HTTP peut lire un log d’erreur tRPC sans formation supplémentaire.
5) Une règle métier peut produire une erreur inattendue#
Il existe un cas particulièrement instructif dans ce projet, rencontré au chapitre 6.
user.delete est réservée aux administrateurs. L’administrateur qui l’appelle a donc le droit d’agir.
Et pourtant :
return { ok: true }; }), delete: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte, // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur). // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie.FORBIDDEN est levé ici — non pas parce que l’utilisateur n’a pas le droit en général, mais parce qu’une règle métier l’empêche dans ce cas précis : on ne supprime pas son propre compte.
👉 Le code d’erreur est le même que celui de isAdmin.
👉 La cause est différente.
C’est intentionnel.
Le client ne voit pas « qui » a levé l’erreur.
Il voit « quelle erreur s’est produite », et un message qui l’explique.
Et c’est tout ce dont il a besoin pour réagir correctement.
6) Ce que le navigateur reçoit d’une erreur imprévue#
Reste un cas que rien de ce qui précède ne couvre : l’erreur que personne n’a levée volontairement. Une requête Prisma qui échoue, une colonne absente après une migration oubliée, un undefined lu par mégarde. tRPC l’emballe dans une TRPCError de code INTERNAL_SERVER_ERROR, et le message qui part vers le navigateur est… celui de l’erreur d’origine.
Pour une erreur Prisma, ce message contient le nom de la méthode appelée, parfois des noms de tables et de colonnes, parfois un extrait de la requête. C’est ce que le projet envoyait, en production, à n’importe quel visiteur qui provoquait une erreur. Et dans le même temps, côté serveur, rien n’était écrit : la route /api/trpc ne journalisait pas les erreurs. Le visiteur en savait plus que l’administrateur.
Deux réglages y répondent, un de chaque côté de la frontière. Le premier, dans core.ts, décide de ce qui sort :
export const t = initTRPC.context<TRPCContext>().create({ transformer: superjson, // Une erreur non prévue (Prisma, réseau, bug) porte un message technique : nom de table, // texte de la requête… En production, le navigateur reçoit un message neutre ; le détail // reste dans les journaux du serveur (onError de la route /api/trpc). errorFormatter({ shape, error }) { if (error.code === "INTERNAL_SERVER_ERROR" && process.env.NODE_ENV === "production") { return { ...shape, message: "Erreur interne du serveur. Réessayez dans un instant." }; } return shape; },});errorFormatter reçoit la forme que tRPC s’apprête à envoyer (shape) et l’erreur elle-même. Pour une erreur interne en production, il remplace le message par une phrase neutre ; les erreurs voulues (FORBIDDEN, CONFLICT…) gardent le leur, rédigé pour l’utilisateur. En développement, rien ne change : le développeur voit le vrai message, et la pile d’appels, que tRPC n’envoie qu’en dehors de la production.
Le second, dans la route, garde la trace :
onError est appelé pour chaque erreur, sur le serveur. Seules les erreurs internes sont écrites : un refus est un fonctionnement normal, et journaliser chaque mot de passe erroné noierait les vraies pannes. L’entrée de la procédure n’est jamais écrite, parce qu’elle peut contenir un mot de passe. Les journaux du conteneur (docker logs) montrent désormais la procédure en cause et l’erreur complète.
Un message d’erreur est une donnée qui sort. Les messages écrits pour l’utilisateur (« Ce slug est déjà utilisé ») peuvent sortir ; les messages écrits par une bibliothèque pour un développeur ne le doivent pas. La règle se pose une fois, à l’endroit où toutes les erreurs passent — pas procédure par procédure.
7) Le flux complet avec erreurs#
Scroller horizontalement si nécessaire
Une erreur traverse le système comme une réussite.
Elle suit le même chemin, remonte proprement, reste lisible.
Un bon backend ne "gère" pas les erreurs partout.
Il les laisse exister au bon endroit, avec le bon signal.
tRPC les transporte ensuite sans les déformer.
🚀 Conclusion#
Nous ne masquons plus les erreurs.
Nous ne les dispersons plus.
👉 Chaque couche lance ce qu’elle sait lancer.
👉 tRPC se charge du reste.
La dernière question reste entière.
Le système produit des erreurs propres.
Mais que renvoie-t-il quand tout se passe bien ?
À suivre#
Les erreurs ne sont plus des accidents à étouffer : elles sont levées avec un code, transportées par tRPC, et seules celles écrites pour l’utilisateur sortent telles quelles. Reste le dernier maillon : ce que le backend renvoie quand tout se passe bien.
Chapitre suivant : maîtriser la sortie. Ne jamais renvoyer une ligne complète, select et omit, le DTO partagé avec le front, et satisfies, qui vérifie sans élargir.