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.valuesans 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, etmatch().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 ;
throwApiErrordans les controllers — la traduction finale d'unResult.
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 unResultmaison 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 desHttpExceptiondepuis n'importe quelle couche — on ne le rétrofitte pas, on le migre.ApiExceptions'applique au nouveau code ; pas de chasse auxthrow new Errorexistants. ApiExceptionn'est pasapiErrqui throw. Lui coller untypeHTTP recréerait le throw métier depuis les services, déjà interdit. La traduction HTTP restethrowApiErrorau 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 lethrow, invite unreturnparasite, double l'API (classe + fonction).throwApiErrorreste : il mappe unResultvers Nest. - Catch
ApiExceptionpour typer les recovers. TypeScript ne type pas les throws ; un.catchredevient le swallow-all deCustomerBalanceService(BRI-1964). Si c'est récupérable,Resultà la source. - Migrer tous les
throw new Errordu legacy dans la même PR. Le contrat d'abord, la migration par attrition ensuite.