Repository: MTES-MCT/trackdechets
Pull request #4110: [TRA-16109] Affichage des erreurs Zod en français
Author: login: GaelFerrand
Labels: name: ready for review
name: ready to merge
Created: 2025-04-10T09:33:34Z | Merged: 2025-04-28T09:44:29Z

# Contexte

J'ai installé [une librairie](https://github.com/aiji42/zod-i18n) pour que les erreurs Zod soient par défaut en français.

Vu que Zod est utilisé côté backend et côté frontend, j'ai mis le fichier d'initialisation dans `@td/validation`, qui est appelé partout. Donc maintenant:
```
// On n'écrit plus
import { z } from "zod";

// Mais
import { z } from "@td/validation";
```

# Points de vigilance pour les intégrateurs

:warning: Cette PR n'est-elle pas un breaking change? :warning:

Si certains intégrateurs se basent sur les messages d'erreur pour piloter leur API, n'est-ce pas un breaking change?

# Ticket Favro

[Afficher les messages d'erreur en français sur le BSFF suite](https://favro.com/widget/ab14a4f0460a99a9d64d4945/75bf894e4c9b3d42b4cb02ca?card=tra-16109)


Diff:
diff --git a/back/src/__tests__/errors.integration.ts b/back/src/__tests__/errors.integration.ts
index 7927e3c7b5..4ef3608835 100644
--- a/back/src/__tests__/errors.integration.ts
+++ b/back/src/__tests__/errors.integration.ts
@@ -71,7 +71,9 @@ describe("Error handling", () => {
       const errors = body.singleResult.errors;
       const error = errors[0];
       expect(error.extensions.code).toEqual("BAD_USER_INPUT");
-      expect(error.message).toEqual("Expected string, received number");
+      expect(error.message).toEqual(
+        "Le type « chaîne de caractères » est attendu mais « nombre » a été reçu"
+      );
     });
   });
 
diff --git a/back/src/bsda/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts b/back/src/bsda/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
index 85daf1ddc9..0264c2f9c6 100644
--- a/back/src/bsda/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
+++ b/back/src/bsda/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
@@ -327,7 +327,7 @@ describe("Mutation.createBsdaRevisionRequest", () => {
     });
 
     expect(errors[0].message).toBe(
-      "Invalid enum value. Expected '06 07 01*' | '06 13 04*' | '10 13 09*' | '16 01 11*' | '16 02 12*' | '17 06 01*' | '17 06 05*' | '08 01 17*' | '08 04 09*' | '12 01 16*' | '15 01 11*' | '15 02 02*' | '16 02 13*' | '16 03 03*' | '16 03 05*' | '17 01 06*' | '17 02 04*' | '17 03 01*' | '17 04 09*' | '17 04 10*' | '17 05 03*' | '17 05 05*' | '17 05 07*' | '17 06 03*' | '17 08 01*' | '17 09 03*', received 'Made up code'"
+      "La valeur « Made up code » n'existe pas dans les options : '06 07 01*' | '06 13 04*' | '10 13 09*' | '16 01 11*' | '16 02 12*' | '17 06 01*' | '17 06 05*' | '08 01 17*' | '08 04 09*' | '12 01 16*' | '15 01 11*' | '15 02 02*' | '16 02 13*' | '16 03 03*' | '16 03 05*' | '17 01 06*' | '17 02 04*' | '17 03 01*' | '17 04 09*' | '17 04 10*' | '17 05 03*' | '17 05 05*' | '17 05 07*' | '17 06 03*' | '17 08 01*' | '17 09 03*'"
     );
   });
 
diff --git a/back/src/bsdasris/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts b/back/src/bsdasris/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
index f852315608..57d9c7a494 100644
--- a/back/src/bsdasris/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
+++ b/back/src/bsdasris/resolvers/mutations/revisionRequest/__tests__/createRevisionRequest.integration.ts
@@ -238,7 +238,7 @@ describe("Mutation.createBsdasriRevisionRequest", () => {
     });
 
     expect(errors[0].message).toBe(
-      "Invalid enum value. Expected '18 01 03*' | '18 02 02*', received 'Made up code'"
+      "La valeur « Made up code » n'existe pas dans les options : '18 01 03*' | '18 02 02*'"
     );
   });
 
diff --git a/back/src/bspaoh/resolvers/mutations/__tests__/createBspaoh.integration.ts b/back/src/bspaoh/resolvers/mutations/__tests__/createBspaoh.integration.ts
index d5994c71a0..64863bb68b 100644
--- a/back/src/bspaoh/resolvers/mutations/__tests__/createBspaoh.integration.ts
+++ b/back/src/bspaoh/resolvers/mutations/__tests__/createBspaoh.integration.ts
@@ -484,7 +484,7 @@ describe("Mutation.Bspaoh.create", () => {
     expect(errors).toEqual([
       expect.objectContaining({
         message:
-          "Invalid enum value. Expected '18 01 02', received '18 01 06*'",
+          "La valeur « 18 01 06* » n'existe pas dans les options : '18 01 02'",
         extensions: expect.objectContaining({
           code: ErrorCode.BAD_USER_INPUT
         })
diff --git a/back/src/server.ts b/back/src/server.ts
index cd99d8be51..bd2b31fa28 100644
--- a/back/src/server.ts
+++ b/back/src/server.ts
@@ -55,6 +55,10 @@ import { userActivationHandler } from "./users/activation";
 import { createUserDataLoaders } from "./users/dataloaders";
 import { getUIBaseURL } from "./utils";
 import configureYup from "./common/yup/configureYup";
+import i18next from "i18next";
+import { z } from "zod";
+import { zodI18nMap } from "zod-i18n-map";
+import translation from "zod-i18n-map/locales/fr/zod.json";
 import { gqlPayloadSizeLimiterPlugin } from "./common/plugins/gqlPayloadSizeLimiterPlugin";
 
 const {
@@ -69,6 +73,15 @@ const {
   GERICO_WEBHOOK_SLUG
 } = process.env;
 
+// Zod in FR
+i18next.init({
+  lng: "fr",
+  resources: {
+    fr: { zod: translation }
+  }
+});
+z.setErrorMap(zodI18nMap);
+
 const Sentry = initSentry();
 configureYup();
 
diff --git a/back/src/users/resolvers/queries/__tests__/membershipRequests.integration.ts b/back/src/users/resolvers/queries/__tests__/membershipRequests.integration.ts
index a4e695cd70..92ccd8ee2a 100644
--- a/back/src/users/resolvers/queries/__tests__/membershipRequests.integration.ts
+++ b/back/src/users/resolvers/queries/__tests__/membershipRequests.integration.ts
@@ -194,7 +194,7 @@ describe("membershipRequests", () => {
 
     // Then
     expect(errors).not.toBeUndefined();
-    expect(errors[0].message).toBe("Required");
+    expect(errors[0].message).toBe("Obligatoire");
   });
 
   it("should return an error if using both ID and orgId", async () => {
diff --git a/front/src/App.tsx b/front/src/App.tsx
index 077a7965ba..dcc1bba3d4 100644
--- a/front/src/App.tsx
+++ b/front/src/App.tsx
@@ -8,6 +8,19 @@ import BrowserDetect from "./BrowserDetect";
 import ErrorBoundary from "./ErrorBoundary";
 import { FeatureFlagsProvider } from "./common/contexts/FeatureFlagsContext";
 import { PermissionsProvider } from "./common/contexts/PermissionsContext";
+import i18next from "i18next";
+import { z } from "zod";
+import { zodI18nMap } from "zod-i18n-map";
+import translation from "zod-i18n-map/locales/fr/zod.json";
+
+// Zod in FR
+i18next.init({
+  lng: "fr",
+  resources: {
+    fr: { zod: translation }
+  }
+});
+z.setErrorMap(zodI18nMap);
 
 // Defines app-wide french error messages for yup
 // See https://github.com/jquense/yup#using-a-custom-locale-dictionary
diff --git a/front/src/form/bsff/utils/schema.ts b/front/src/form/bsff/utils/schema.ts
index d0e67ae4b1..d4d2d4b0e5 100644
--- a/front/src/form/bsff/utils/schema.ts
+++ b/front/src/form/bsff/utils/schema.ts
@@ -62,7 +62,7 @@ export const validationSchema = yup.object({
           .number()
           .nullable()
           .required("Le poids du contenu est un champ requis")
-          .positive("La masse du contenu doit être supérieur 0")
+          .positive("La masse du contenu doit être supérieur à 0")
       })
     )
     .min(1, "Le nombre de contenants doit être supérieur ou égal à 1"),
diff --git a/package-lock.json b/package-lock.json
index 7d6ae0aec0..6fad703e50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -95,6 +95,7 @@
         "graphql-tag": "^2.12.6",
         "graylog2": "^0.2.1",
         "helmet": "^6.2.0",
+        "i18next": "^24.2.3",
         "inspector-api": "^1.4.7",
         "ioredis": "^4.28.0",
         "jose": "^4.11.2",
@@ -153,6 +154,7 @@
         "xstate": "^4.37.0",
         "yup": "^0.32.11",
         "zod": "^3.21.4",
+        "zod-i18n-map": "^2.27.0",
         "zxcvbn": "^4.4.2"
       },
       "devDependencies": {
@@ -8243,9 +8245,10 @@
       }
     },
     "node_modules/@babel/runtime": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
-      "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+      "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
+      "license": "MIT",
       "dependencies": {
         "regenerator-runtime": "^0.14.0"
       },
@@ -31654,6 +31657,37 @@
         "node": ">=10.17.0"
       }
     },
+    "node_modules/i18next": {
+      "version": "24.2.3",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
+      "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://locize.com"
+        },
+        {
+          "type": "individual",
+          "url": "https://locize.com/i18next.html"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.26.10"
+      },
+      "peerDependencies": {
+        "typescript": "^5"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/iconv-lite": {
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -50941,6 +50975,16 @@
         "url": "https://github.com/sponsors/colinhacks"
       }
     },
+    "node_modules/zod-i18n-map": {
+      "version": "2.27.0",
+      "resolved": "https://registry.npmjs.org/zod-i18n-map/-/zod-i18n-map-2.27.0.tgz",
+      "integrity": "sha512-ORu9XpiVh3WDiEUs5Cr9siGgnpeODoBsTIgSD8sQCH9B//f9KowlzqHUEdPYb3vFonaSH8yPvPCOFM4niwp3Sg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "i18next": ">=21.3.0",
+        "zod": ">=3.17.0"
+      }
+    },
     "node_modules/zwitch": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 2b3a630f5a..e17bad325f 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
     "graphql-tag": "^2.12.6",
     "graylog2": "^0.2.1",
     "helmet": "^6.2.0",
+    "i18next": "^24.2.3",
     "inspector-api": "^1.4.7",
     "ioredis": "^4.28.0",
     "jose": "^4.11.2",
@@ -178,6 +179,7 @@
     "xstate": "^4.37.0",
     "yup": "^0.32.11",
     "zod": "^3.21.4",
+    "zod-i18n-map": "^2.27.0",
     "zxcvbn": "^4.4.2"
   },
   "devDependencies": {


Reviews:
user: login: GaelFerrand
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:37:21Z
user: login: GaelFerrand
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:38:00Z
user: login: benoitguigal
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T06:49:05Z
user: login: providenz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T08:44:30Z
user: login: Riron
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T09:42:11Z

Review comments:
diff_hunk: @@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from "@td/validation";
path: back/src/adminRequest/validation/schema.ts
user: login: GaelFerrand
body: 2 caveheats:
- Il faudra bien faire attention à l'avenir d'utiliser cet import. Risque d'erreur non négligeable
- Je n'ai pas fait le travail pour Yup, puisque de toute façon on est censés le virer
created_at: 2025-04-10T09:37:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+import i18next from "i18next";
path: libs/shared/validation/src/fr-zod.ts
user: login: GaelFerrand
body: La seule vraie nouveauté (tout le reste c'est des imports et de la config).
created_at: 2025-04-10T09:38:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: Riron
created_at: 2025-04-18T14:03:08Z
body: Je vais faire le relou, mais serait pas moins error prone de faire un appel de fonction qui set l'error map côté front et un côté back dans `server.ts` par exemple, et ensuite de continuer à importer depuis zod ?
Quitte à garder le package validation d'ailleurs dans lequel on pourrait regroupper plein de trucs ensuite, mais qu'il exporte ce genre de chose:
```js
 export function setFrenchErrorMap() {
  i18next.init({
    lng: "fr",
    resources: {
      fr: { zod: translation }
    }
  });
  z.setErrorMap(zodI18nMap);
 }
```
Et qu'on appelle partout ou on le souhaite `setFrenchErrorMap`(je pense dans 2-3 endroits max en fait)
author_association: COLLABORATOR
user: login: tristanrobert
created_at: 2025-04-25T16:30:45Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/mtes-mct/pr-checks/ce8754d5-7e59-4758-abff-e19953424760?refs=994ce181a4a02ea7536c293483b726e95837d967&source=prChecksComment)



author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-28T08:48:16Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=MTES-MCT_trackdechets&pullRequest=4110) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=MTES-MCT_trackdechets&pullRequest=4110&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=MTES-MCT_trackdechets&pullRequest=4110&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=MTES-MCT_trackdechets&pullRequest=4110&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_trackdechets&pullRequest=4110&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_trackdechets&pullRequest=4110&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=MTES-MCT_trackdechets&pullRequest=4110)


author_association: NONE