Aller au contenu

Erreurs & Result — le pattern no-throw

La décision

Dans __new/, une erreur métier est une valeur, jamais une exception. Toute fonction faillible retourne Result<T, E> (Ok/Err de space-lift). Le throw est réservé à la frontière HTTP et aux erreurs techniques.

Pourquoi

throw court-circuite le système de typage : rien dans la signature n'indique qu'une fonction peut échouer, et rien ne force l'appelant à gérer l'échec. Avec Result :

  • L'échec fait partie de la signature. Impossible d'accéder à result.value sans avoir traité result.ok === false.
  • Les codes d'erreur sont des littéraux ('project-not-found' as const) : le compilateur connaît l'ensemble des erreurs possibles d'un appel, et match().exhaustive() (ts-pattern) garantit qu'aucun cas n'est oublié.
  • Le flux d'erreur se lit dans le code (if (!result.ok) return result), pas dans une stack invisible de try/catch.

Comment ça s'assemble

apiErr — l'erreur métier structurée

api-err.ts expose apiErr(params), qui construit un Err structuré :

return apiErr({
  error: 'presentation-already-completed' as const, // code, littéral
  message: 'Presentation is already completed',
  type: 'conflict', // catégorie HTTP
})

type appartient à ApiErrorType — aujourd'hui not-found (404), validation-body (400), validation-pg (500), conflict (409). La liste est en construction active : elle s'enrichit au fil des besoins réels, pas spéculativement. 401/403 n'y figurent pas : l'authn/authz est portée par les guards, déjà à la frontière HTTP.

Le log à la naissance de l'erreur

apiErr accepte logger et warn : l'erreur se loggue là où elle naît, avec son contexte (body). Objectif : polluer le moins possible le code applicatif avec la gestion d'erreur — pas de second pipeline « construire l'erreur, puis penser à la logger ».

throwApiError — le seul point de throw

Le controller est le seul à convertir un Err en exception, via handle-backend-error.ts : match exhaustif ApiErrorType → exception NestJS, attrapée par le filtre global all-exceptions.filter.ts. Le throw n'existe que parce que NestJS fonctionne ainsi ; il est confiné à la dernière ligne du voyage.

const result = await ProjectFinancingRequestPresentationService.complete(projectId, presentationId)
if (!result.ok) throwApiError(result)

Transactions : Err = rollback

ky_safePgTransaction couple le pattern au transactionnel : retourner Err dans le callback déclenche le rollback, Ok committe. La gestion d'erreur et l'atomicité sont le même geste — impossible de committer un demi-état sur un chemin d'échec oublié.

La doctrine du throw

Le throw n'est jamais un canal métier. Il reste légitime pour :

  • les erreurs techniques (driver PG, système) — l'appelant ne peut rien en faire de sensé, le filtre global les transforme en 500 ;
  • les guards (authn/authz) — frontière HTTP par nature ;
  • throwApiError dans les controllers — la traduction finale.

Trace de bout en bout

POST …/documents/confirm-upload (PFR) : le controller valide le body et early-return sur Err → le service vérifie les préfixes S3 (apiErr si invalide), checke l'existence des objets S3, ouvre la transaction → le repository insère avec trx → toute Err remonte telle quelle, rollback compris → le controller traduit via throwApiError.

Limites assumées & trajectoire

  • space-lift n'est plus activement maintenu. Introduit pour son API pratique, il est partagé avec le legacy et le front (lift()), ce qui rend son remplacement structurant. Candidats identifiés : neverthrow (le standard maintenu) ou un Result maison d'une trentaine de lignes — notre usage se limite à Ok/Err/.ok. Aucune décision prise ; le pattern, lui, ne changera pas.
  • Le legacy (hors __new/) throw des HttpException depuis n'importe quelle couche — on ne le rétrofitte pas, on le migre.