Aller au contenu

Erreurs & Result — le pattern no-throw

La décision

Dans __new/, une erreur gérable est une valeur, jamais une exception. Toute fonction dont l'échec a un consommateur retourne Result<T, E> (Ok/Err de space-lift).

La ligne n'est pas métier vs technique : un fail P2P Lemonway est technique et gérable → Result. Un schéma DB invalide est un invariant cassé → l'appelant ne peut rien en faire → throw new ApiException → 500 opaque.

Le mot-clé au callsite est le canal : return apiErr(...) vs throw new ApiException(...). Le throw HTTP (throwApiError) reste confiné au controller — c'est un mapper Result → Nest, pas un invariant.

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.

Un throw nu (new Error('…')) a le défaut inverse : pas de code stable, pas de log structuré, et le premier .catch générique (cf. insertWts → not-enough-balance) avale aussi les vrais 500.

Comment ça s'assemble

apiErr — l'erreur gérable 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.

validation-pg / provider-error dans un Result n'ont aucun consommateur (le controller les mappe en 500 vide) : préférer throw new ApiException à la source.

Le log à la naissance de l'erreur

apiErr et le constructeur ApiException acceptent logger : l'erreur se loggue là où elle naît. ApiException pose un Error sur err (serializer pino) et le contexte métier dans body — pas un cause imbriqué, que le hook pino ne unwrap pas. Un payload HTTP (Axios) se sanitise dans body (formatHttpErrorForLog), il n'est pas un cause. 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 — la traduction HTTP

Le controller est le seul à convertir un Err gérable en exception NestJS, via handle-backend-error.ts : match exhaustif ApiErrorType → exception NestJS, attrapée par le filtre global all-exceptions.filter.ts. Ce throw-là n'existe que parce que NestJS fonctionne ainsi.

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

ApiException — le throw interne contrôlé

api-exception.ts est le pendant de apiErr pour l'ingérable. Même réflexe (code kebab + log au callsite), canal opposé : throw new ApiException(...), pas de type HTTP, le client reçoit un 500 opaque.

Consommateur réel — HTTP Axonaut qui échoue : l'appelant (ProjectOwnerManagementFeesInvoicesService.getAllForProject) ne catch pas, le 500 est le bon contrat.

if (!axiosResult.ok) {
  throw new ApiException({
    error: 'axonaut-invoices-fetch-failed' as const,
    message: `Axonaut invoices fetch failed for company ${params.companyId}`,
        body: {
          httpError: formatHttpErrorForLog(axiosResult.error),
          companyId: params.companyId,
        },
        logger,
  })
}

Voir axonaut.api.ts. Même pattern pour un schéma row Kysely invalide : pgParse_zod (BRI-1345).

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é.

Les sentinels Transaction aborted / Dry Run restent des Error string-matchés par le helper : ce n'est pas un invariant, c'est du contrôle de flux. Ne pas les remplacer par ApiException.

La doctrine du throw

Le throw n'est jamais un canal gérable. Trois throws légitimes, et seulement ceux-là :

  • throw new ApiException — invariant cassé / throw tiers ingérable (500 opaque) ;
  • les guards (authn/authz) — frontière HTTP par nature ;
  • throwApiError dans les controllers — la traduction finale d'un Result.

Si un jour on doit catch une ApiException pour brancher : la source était gérable, elle doit return apiErr / Err. Ne pas ajouter de catch sites.

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. Un schéma row invalide throw ApiException dans pgParse_zod et bubble en 500.

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. ApiException s'applique au nouveau code ; pas de chasse aux throw new Error existants.
  • ApiException n'est pas apiErr qui throw. Lui coller un type HTTP recréerait le throw métier depuis les services, déjà interdit. La traduction HTTP reste throwApiError au controller.

Alternatives écartées

  • Catégories fixes « métier → Result / technique → throw ». Trop large : un fail Lemonway est technique et gérable (BRI-1434, thread Slack 2026-06-29). La ligne est gérable vs ingérable, décidée au callsite.
  • Helper throwApiException() qui throw tout seul. Cache le throw, invite un return parasite, double l'API (classe + fonction). throwApiError reste : il mappe un Result vers Nest.
  • Catch ApiException pour typer les recovers. TypeScript ne type pas les throws ; un .catch redevient le swallow-all de CustomerBalanceService (BRI-1964). Si c'est récupérable, Result à la source.
  • Migrer tous les throw new Error du legacy dans la même PR. Le contrat d'abord, la migration par attrition ensuite.