createNestTestApp n'accepte pas createEqsApp sans cast : les deux packages ne s'accordent pas sur le type du démarreur #32

Closed
opened 2026-09-09 16:29:33 +00:00 by nicolas-fryder · 1 comment

Constaté le 2026-09-09 sur le premier satellite réel monté depuis eqs-satellite-template, en écrivant le premier test d'intégration métier. @eqs/* 0.7.0, TypeScript 5.9.3, Node 22.18.

createNestTestApp({ createEqsApp, module, auth, source, reste }) est le montage que @eqs/testing/nest documente comme prêt à l'emploi — son propre commentaire dit « createEqsApp importé par le satellite : @eqs/testing n'impose pas la version du profil ». Mais lui passer le vrai createEqsApp ne type-check pas.

La commande et la sortie

pnpm --filter api exec tsc --noEmit -p tsconfig.json
test/integration/app-de-test.ts(63,5): error TS2322: Type '<S extends z.ZodObject
= …>(options: OptionsEqsApp<...>) => Promise<...>' is not assignable to type
'CreerEqsApp'.
  Property 'module' is missing in type 'Record<string, unknown>' but required
  in type 'OptionsEqsApp<...>'.

Où

  • @eqs/testing, dist/nest.d.ts : export type CreerEqsApp = (options: Record<string, unknown>) => Promise<AppNestDemarree>
  • @eqs/platform-nest : createEqsApp exige OptionsEqsApp<S>, générique sur le schéma de configuration, où module est requis.

Un Record<string, unknown> n'est pas assignable à un type qui exige une propriété précise, donc la fonction réelle n'est pas assignable au type que le paramètre déclare.

Ce que j'attendais

Que le code montré par la documentation du package compile tel quel. C'est précisément le point de contact que createNestTestApp existe pour fournir : si chaque satellite doit inventer un cast à cet endroit, l'abstraction ne tient pas sa promesse.

Ce que ça coûte

Contournable, mais trente fois, et chaque fois par un cast que la personne devra justifier en revue. Un cast copié sans être compris est aussi le genre de chose qui masque, plus tard, une vraie incompatibilité de signature entre les deux packages — c'est-à-dire exactement ce que le typage devait attraper.

Ce que je proposerais

Rendre CreerEqsApp générique sur le schéma, à l'image de createEqsApp :

export type CreerEqsApp = <S>(options: OptionsEqsApp<S>) => Promise<AppNestDemarree>

ce qui suppose que @eqs/testing connaisse OptionsEqsApp — ce qu'il veut visiblement éviter, d'où le Record<string, unknown>. À défaut, faire que OptionsAppNest.createEqsApp accepte une signature plus large côté paramètre (un paramètre contravariant : (options: never) => Promise<AppNestDemarree> accepterait tout démarreur), ou fournir dans @eqs/testing/nest un helper qui prend typeof createEqsApp sans que chaque satellite ait à s'en occuper.

Je ne sais pas laquelle des trois est la bonne — c'est votre frontière de packages, pas la mienne. Ce qui compte : que le montage documenté compile.

Contournement en place

createEqsApp: createEqsApp as unknown as CreerEqsApp

avec un commentaire renvoyant à ce ticket, dans apps/api/test/integration/app-de-test.ts du satellite. Choisi plutôt qu'affaiblir le typage de tout l'appel. À retirer quand ce sera tranché.

Constaté le 2026-09-09 sur le premier satellite réel monté depuis `eqs-satellite-template`, en écrivant le premier test d'intégration métier. `@eqs/*` 0.7.0, TypeScript 5.9.3, Node 22.18. `createNestTestApp({ createEqsApp, module, auth, source, reste })` est le montage que `@eqs/testing/nest` documente comme prêt à l'emploi — son propre commentaire dit « `createEqsApp` importé par le satellite : `@eqs/testing` n'impose pas la version du profil ». Mais lui passer le vrai `createEqsApp` **ne type-check pas**. ## La commande et la sortie ``` pnpm --filter api exec tsc --noEmit -p tsconfig.json ``` ``` test/integration/app-de-test.ts(63,5): error TS2322: Type '<S extends z.ZodObject = …>(options: OptionsEqsApp<...>) => Promise<...>' is not assignable to type 'CreerEqsApp'. Property 'module' is missing in type 'Record<string, unknown>' but required in type 'OptionsEqsApp<...>'. ``` ## Où - `@eqs/testing`, `dist/nest.d.ts` : `export type CreerEqsApp = (options: Record<string, unknown>) => Promise<AppNestDemarree>` - `@eqs/platform-nest` : `createEqsApp` exige `OptionsEqsApp<S>`, générique sur le schéma de configuration, où `module` est **requis**. Un `Record<string, unknown>` n'est pas assignable à un type qui exige une propriété précise, donc la fonction réelle n'est pas assignable au type que le paramètre déclare. ## Ce que j'attendais Que le code montré par la documentation du package compile tel quel. C'est précisément le point de contact que `createNestTestApp` existe pour fournir : si chaque satellite doit inventer un cast à cet endroit, l'abstraction ne tient pas sa promesse. ## Ce que ça coûte Contournable, mais **trente fois**, et chaque fois par un cast que la personne devra justifier en revue. Un cast copié sans être compris est aussi le genre de chose qui masque, plus tard, une vraie incompatibilité de signature entre les deux packages — c'est-à-dire exactement ce que le typage devait attraper. ## Ce que je proposerais Rendre `CreerEqsApp` générique sur le schéma, à l'image de `createEqsApp` : ```ts export type CreerEqsApp = <S>(options: OptionsEqsApp<S>) => Promise<AppNestDemarree> ``` ce qui suppose que `@eqs/testing` connaisse `OptionsEqsApp` — ce qu'il veut visiblement éviter, d'où le `Record<string, unknown>`. À défaut, faire que `OptionsAppNest.createEqsApp` accepte une signature plus large côté paramètre (un paramètre contravariant : `(options: never) => Promise<AppNestDemarree>` accepterait tout démarreur), ou fournir dans `@eqs/testing/nest` un helper qui prend `typeof createEqsApp` sans que chaque satellite ait à s'en occuper. Je ne sais pas laquelle des trois est la bonne — c'est votre frontière de packages, pas la mienne. Ce qui compte : que le montage documenté compile. ## Contournement en place ```ts createEqsApp: createEqsApp as unknown as CreerEqsApp ``` avec un commentaire renvoyant à ce ticket, dans `apps/api/test/integration/app-de-test.ts` du satellite. Choisi plutôt qu'affaiblir le typage de tout l'appel. À retirer quand ce sera tranché.
Author
Owner

Vrai, reproduit, et pire que ce que vous avez vu : il y avait deux incompatibilités, pas une.

La reproduction, dans un clone du gabarit, apps/api/test/ :

import { createNestTestApp } from '@eqs/testing/nest'
import { createEqsApp } from '@eqs/platform-nest'
export const monter = () => createNestTestApp({ createEqsApp, module: AppModule })
error TS2322: Type '<S extends z.ZodObject = …>(options: OptionsEqsApp<...>) => Promise<...>'
  is not assignable to type 'CreerEqsApp'.
    Property 'module' is missing in type 'Record<string, unknown>' …

Votre analyse est exacte jusqu'à la contravariance.

Le second écart, caché derrière le premier. En isolant le seul type de retour :

error TS2322: Type 'EqsApp<…>' is not assignable to type 'AppNestDemarree'.
  Types of property 'url' are incompatible.
    Type 'string | null' is not assignable to type 'string | undefined'.

EqsApp.url vaut string | null quand l'application n'écoute pas ; AppNestDemarree.url était déclaré string | undefined. TypeScript ne signale que la première incompatibilité rencontrée — votre correctif proposé (rendre CreerEqsApp générique) n'aurait donc fait qu'exposer celle-là. Ça vaut la peine d'être dit : le ticket a rapporté une mesure juste, et la mesure cachait la moitié du problème.

Ce que révèle aussi la vérification : personne n'appelait createNestTestApp — ni dans le socle, ni dans eqs-demo. Un montage documenté comme prêt à l'emploi que rien n'avait jamais compilé. C'est ça, le vrai défaut ; le type n'en était que le symptôme.

Le correctif.

CreerEqsApp déclare never en paramètre :

export type CreerEqsApp = (options: never) => Promise<AppNestDemarree>

never est assignable à n'importe quel type de paramètre : par cette même contravariance, tout démarreur est accepté, sans que @eqs/testing ait à connaître OptionsEqsApp — c'est ce qui lui permet de continuer à ne pas imposer la version du profil. url accepte désormais null, et le site d'appel traite les deux absences.

Le prix est un cast, à l'intérieur de createNestTestApp : un, au lieu de trente. Il est commenté sur place, avec ce qu'il cesse de vérifier.

J'ai essayé votre autre piste avant — la syntaxe de méthode, dont la bivariance est l'échappatoire documentée. Elle ne marche pas ici, mesuré :

interface AvecMethode { f(o: Record<string, unknown>): void }
const a: AvecMethode = { f: vrai }   // error TS2322

La bivariance demande que les deux côtés soient des déclarations de méthode ; un satellite écrit { createEqsApp }, donc une propriété. Un Pick<OptionsAppNest, …> la reperd également — un type mappé recopie les membres sous une autre forme.

La barrière, qui compte plus que le correctif. Aucun des deux packages ne pouvait voir l'écart seul. @eqs/platform-nest gagne donc @eqs/testing en dépendance de développement et un fichier qui ne teste aucun comportement — sa valeur est qu'il passe par tsc :

const montage: OptionsAppNest = { createEqsApp, module: ModuleDeFixture }

C'est le seul endroit du socle où les deux se regardent, et il rougit si l'un des deux bouge. pnpm check vert sur les dix packages.

Retirez votre cast à la prochaine version de @eqs/* (le changeset est dans la branche fix/tickets-socle-du-premier-satellite). Si as unknown as CreerEqsApp reste nécessaire après ça, rouvrez — ce serait un troisième écart.

Vrai, reproduit, et **pire que ce que vous avez vu** : il y avait deux incompatibilités, pas une. **La reproduction**, dans un clone du gabarit, `apps/api/test/` : ```ts import { createNestTestApp } from '@eqs/testing/nest' import { createEqsApp } from '@eqs/platform-nest' export const monter = () => createNestTestApp({ createEqsApp, module: AppModule }) ``` ``` error TS2322: Type '<S extends z.ZodObject = …>(options: OptionsEqsApp<...>) => Promise<...>' is not assignable to type 'CreerEqsApp'. Property 'module' is missing in type 'Record<string, unknown>' … ``` Votre analyse est exacte jusqu'à la contravariance. **Le second écart, caché derrière le premier.** En isolant le seul type de retour : ``` error TS2322: Type 'EqsApp<…>' is not assignable to type 'AppNestDemarree'. Types of property 'url' are incompatible. Type 'string | null' is not assignable to type 'string | undefined'. ``` `EqsApp.url` vaut `string | null` quand l'application n'écoute pas ; `AppNestDemarree.url` était déclaré `string | undefined`. TypeScript ne signale que la première incompatibilité rencontrée — **votre correctif proposé (rendre `CreerEqsApp` générique) n'aurait donc fait qu'exposer celle-là.** Ça vaut la peine d'être dit : le ticket a rapporté une mesure juste, et la mesure cachait la moitié du problème. **Ce que révèle aussi la vérification** : personne n'appelait `createNestTestApp` — ni dans le socle, ni dans `eqs-demo`. Un montage documenté comme prêt à l'emploi que rien n'avait jamais compilé. C'est ça, le vrai défaut ; le type n'en était que le symptôme. **Le correctif.** `CreerEqsApp` déclare `never` en paramètre : ```ts export type CreerEqsApp = (options: never) => Promise<AppNestDemarree> ``` `never` est assignable à n'importe quel type de paramètre : par cette même contravariance, **tout** démarreur est accepté, sans que `@eqs/testing` ait à connaître `OptionsEqsApp` — c'est ce qui lui permet de continuer à ne pas imposer la version du profil. `url` accepte désormais `null`, et le site d'appel traite les deux absences. Le prix est un cast, à l'intérieur de `createNestTestApp` : un, au lieu de trente. Il est commenté sur place, avec ce qu'il cesse de vérifier. **J'ai essayé votre autre piste avant** — la syntaxe de méthode, dont la bivariance est l'échappatoire documentée. Elle ne marche pas ici, mesuré : ```ts interface AvecMethode { f(o: Record<string, unknown>): void } const a: AvecMethode = { f: vrai } // error TS2322 ``` La bivariance demande que les deux côtés soient des déclarations de méthode ; un satellite écrit `{ createEqsApp }`, donc une propriété. Un `Pick<OptionsAppNest, …>` la reperd également — un type mappé recopie les membres sous une autre forme. **La barrière, qui compte plus que le correctif.** Aucun des deux packages ne pouvait voir l'écart seul. `@eqs/platform-nest` gagne donc `@eqs/testing` en dépendance de développement et un fichier qui ne teste aucun comportement — sa valeur est qu'il passe par `tsc` : ```ts const montage: OptionsAppNest = { createEqsApp, module: ModuleDeFixture } ``` C'est le seul endroit du socle où les deux se regardent, et il rougit si l'un des deux bouge. `pnpm check` vert sur les dix packages. **Retirez votre cast** à la prochaine version de `@eqs/*` (le changeset est dans la branche `fix/tickets-socle-du-premier-satellite`). Si `as unknown as CreerEqsApp` reste nécessaire après ça, rouvrez — ce serait un troisième écart.
nicolas-fryder referenced this issue from a commit 2026-09-09 20:28:21 +00:00
nicolas-fryder referenced this issue from a commit 2026-09-09 21:51:55 +00:00
nicolas-fryder referenced this issue from a commit 2026-10-07 08:03:12 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#32
No description provided.