Skip to content

Pièges fréquents ​

Les huit pièges du plan, puis ceux rencontrés en construisant TaskFlow avec les versions actuelles (NestJS 12, TypeORM 1.1, BullMQ 6). Chaque piège suit le même schéma : ce que vous observez, pourquoi cela arrive, comment le corriger, et le chapitre qui traite le sujet en détail.

PiègeCe que vous voyezChapitre
await oubliéErreur perdue, réponse envoyée avant la fin de l'écritureJ1
synchronize: true en productionColonne supprimée avec ses donnéesJ4
Entités renvoyées brutespasswordHash dans le JSONJ8
Dépendance circulaireL'application ne démarre pasJ3
État mutable dans un singletonDonnées d'un utilisateur chez un autre, sous chargeJ3
Typage sans validationDonnées invalides acceptéesJ2
Égalité et thisComparaison fausse, this vaut undefinedJ1
@Res() dans un contrôleurSérialisation ignorée, requête qui ne répond pasJ2
Métadonnées de type réduites à Object"8080" reste une chaîne, dépendance introuvableJ7
reflect-metadata dans les testsReflect.getMetadata is not a functionJ10
ConditionalModule et chaîneModule chargé alors que la variable est absenteJ9
BullMQ 6 sans ioredisCrash au chargement de la fileJ9

Les huit pièges du plan ​

1. await oublié ​

Symptôme. Une route répond 201 alors que l'écriture a échoué, une erreur apparaît dans les logs après la réponse, un try/catch n'attrape rien. Parfois le processus s'arrête sur un rejet de promesse non géré.

Cause. Une fonction async appelée sans await renvoie immédiatement une Promise, et le code appelant continue. Le try/catch qui l'entoure est déjà terminé quand la promesse échoue. Une promesse rejetée que personne n'écoute est signalée par Node comme rejet non géré ; depuis Node 15, le comportement par défaut est d'arrêter le processus. Variante fréquente : items.forEach(async (item) => …), car forEach n'attend aucune des promesses.

Correction.

  • Mettez await devant chaque appel asynchrone, et remplacez forEach par for … of (séquentiel) ou Promise.all(items.map(…)) (concurrent).
  • Activez la règle de lint qui détecte les promesses flottantes : @typescript-eslint/no-floating-promises avec ESLint, typescript/no-floating-promises avec oxlint (configuration par défaut du starter Nest 12, vérifiée : un save(); sans await est une erreur).
  • Quand ne pas attendre est voulu, dites-le avec void :
ts
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  setupApp(app);
  setupSwagger(app);
  await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();

Chapitres : J1, async/await ; J9, erreurs asynchrones pour les listeners et les crons, que personne n'attend.

2. synchronize: true en production ​

Symptôme. Après le renommage d'une propriété d'entité, la colonne est supprimée et recréée vide au redémarrage. Ou le schéma de production a changé sans aucune trace dans le dépôt.

Cause. synchronize: true aligne le schéma sur les entités à chaque démarrage, comme ddl-auto=update en plus agressif : TypeORM peut supprimer des colonnes, sans historique ni relecture.

Correction. synchronize: false partout, sauf sur une base jetable. Le schéma évolue par des migrations générées (npm run migration:generate src/database/migrations/Nom), relues, versionnées dans git, puis appliquées (npm run migration:run). TaskFlow partage ces options entre l'application et le CLI TypeORM :

ts
// Shared by the Nest app (database.module.ts) and the TypeORM CLI
// (data-source.ts), so migrations run against the same settings.
export function postgresOptions(db: DatabaseConfig): DataSourceOptions {
  return {
    type: 'postgres',
    ...db,
    // Never true outside a throwaway database: it alters tables to match the
    // entities and can drop columns with their data. Use migrations instead.
    synchronize: false,
    migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
    migrationsTableName: 'typeorm_migrations',
  };
}

Perte de données

Une colonne supprimée par synchronize emporte ses données. Ne pointez jamais une configuration de développement avec synchronize: true vers une base qui contient des données à garder.

Chapitres : J4, synchronize, J5, migrations.

3. Entités TypeORM renvoyées brutes ​

Symptôme. La réponse JSON contient passwordHash ou des colonnes internes.

Cause. Nest sérialise tel quel l'objet renvoyé par le handler. Une entité contient toutes ses colonnes, y compris celles qui ne doivent pas sortir.

Correction. Deux options : un DTO de réponse qui ne contient que les champs publics, ou @Exclude() sur l'entité avec ClassSerializerInterceptor actif. TaskFlow utilise la seconde (intercepteur enregistré via APP_INTERCEPTOR) ; vérifié par le test e2e, la réponse de register ne contient pas passwordHash.

ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 254, unique: true })
  email: string;

  // Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
  // @ApiHideProperty keeps it out of the OpenAPI schema as well.
  @Exclude()
  @ApiHideProperty()
  @Column({ name: 'password_hash', type: 'varchar' })
  passwordHash: string;

  @Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
  role: Role;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

@Exclude() ne s'applique qu'à une instance de la classe : un objet recopié ({ ...user }) ou le résultat d'une requête brute n'a plus les métadonnées, et le champ repart dans la réponse.

@Exclude() ne concerne que le JSON renvoyé. Le schéma OpenAPI est construit à la compilation par le plugin Swagger : sans @ApiHideProperty(), passwordHash y serait décrit alors qu'aucune réponse ne le contient (J11).

Chapitre : J8, utilisateurs.

4. Dépendance circulaire entre modules ​

Symptôme. L'application refuse de démarrer : Nest ne peut pas résoudre une dépendance, ou signale qu'un élément d'imports est undefined, et suggère forwardRef().

Cause. Deux modules s'importent mutuellement (ou deux providers s'injectent l'un l'autre). Au niveau des fichiers, un import circulaire peut aussi laisser une classe undefined au moment où un décorateur la lit.

Correction. Revoyez le découpage avant de multiplier les forwardRef :

  • extrayez la partie commune dans un troisième module que les deux importent ;
  • inversez la dépendance par un événement : dans TaskFlow, TasksService émet task.status.changed et NotificationsListener l'écoute, sans import entre les deux modules ;
  • n'exportez que ce dont l'autre module a besoin (ProjectsModule exporte ProjectsService, pas son store).

En dernier recours, forwardRef(() => AutreModule) dans les imports des deux modules, et @Inject(forwardRef(() => AutreService)) sur les paramètres concernés.

Chapitres : J3, modules ; différences, dépendances circulaires.

5. État mutable dans un singleton ​

Symptôme. Sous charge, un utilisateur reçoit des données qui appartiennent à un autre. En local, avec une requête à la fois, tout fonctionne.

Cause. Un provider est un singleton partagé par toutes les requêtes. Dans Spring, le risque vient des threads concurrents ; dans Node, il vient de l'entrelacement : à chaque await, l'event loop peut exécuter une autre requête, qui réécrit le champ.

ts
// Illustration : à ne pas faire
@Injectable()
export class ReportService {
  private currentUser: AuthUser; // partagé par toutes les requêtes

  async build(query: ProjectQueryDto, user: AuthUser) {
    this.currentUser = user;
    const projects = await this.projects.findAll(query, user); // une autre requête passe ici
    return this.format(projects, this.currentUser);            // ... et a changé currentUser
  }
}

Correction. Passez les données de la requête en paramètre : dans TaskFlow, chaque méthode de service reçoit user: AuthUser. Si un contexte par requête est indispensable, Scope.REQUEST recrée le provider (et toute la chaîne qui en dépend) à chaque requête, avec un coût ; AsyncLocalStorage de Node (recette Async local storage) porte un contexte sans changer les scopes.

Chapitre : J3, scopes.

6. Typage TS sans validation à l'exécution ​

Symptôme. Une route accepte { "name": 42, "admin": true }, ou query.page reste la chaîne "2" et les comparaisons donnent des résultats faux sans erreur.

Cause. Les types TypeScript sont effacés à la compilation. Un DTO n'est validé que si un ValidationPipe est actif et que le DTO est une classe (une interface ne laisse aucune trace).

Correction. Un ValidationPipe global, déclaré une fois via APP_PIPE avec whitelist, forbidNonWhitelisted et transform, des DTO en classes décorées par class-validator, et des pipes pour les paramètres simples (ParseUUIDPipe, ParseEnumPipe). Vérifié par les e2e de TaskFlow : champ inconnu → 400, name: '' → 400, ?page=0 → 400, ?status=LATE → 400, /v1/projects/not-a-uuid → 400. La même règle vaut pour la configuration : TaskFlow valide les variables d'environnement au démarrage (J7).

Chapitre : J2, ValidationPipe.

7. Égalité et this ​

Symptôme. Une comparaison se comporte différemment selon qu'elle utilise == ou === ; une méthode passée en callback lève TypeError: Cannot read properties of undefined.

Cause.

  • == convertit les types avant de comparer : '1' == 1, 0 == '' et null == undefined sont vrais.
  • === compare les objets par référence, comme == en Java, et il n'existe pas de equals() : deux Date égales ne sont pas ===. À l'inverse, les chaînes sont des valeurs primitives : 'abc' === 'abc' est vrai, là où Java compare des références.
  • this dépend de la façon dont la fonction est appelée, pas de l'endroit où elle est définie. items.map(this.format) détache la méthode de son objet : à l'intérieur, this vaut undefined.

Correction. Utilisez toujours === et !== (la règle eqeqeq, disponible dans ESLint et oxlint, l'impose ; elle n'est pas activée dans TaskFlow), comparez les dates avec getTime(), et passez des fonctions fléchées, qui capturent le this englobant : items.map((item) => this.format(item)). C'est ce que fait TaskFlow pour genReqId dans la configuration du logger.

Chapitre : J1.

8. @Res() dans un contrôleur ​

Symptôme. La sérialisation (@Exclude) ou la transformation de réponse d'un intercepteur ne s'applique plus sur une route ; ou la requête reste sans réponse.

Cause. Injecter l'objet réponse avec @Res() fait passer le handler en mode manuel : Nest n'envoie plus la valeur renvoyée, c'est à vous d'appeler res.json() ou res.send(). Ce que les intercepteurs font de la valeur renvoyée est perdu, et un oubli d'envoi laisse la requête pendante.

Correction. Renvoyez une valeur et utilisez @HttpCode(), @Header() ou une exception. Si vous avez besoin de l'objet réponse pour un en-tête ou un cookie, @Res({ passthrough: true }) le donne tout en laissant Nest envoyer la valeur renvoyée. Pour un fichier, renvoyez un StreamableFile.

Chapitre : J2, pièges.

Pièges vérifiés sur TaskFlow ​

Métadonnées de type réduites à Object ​

Symptôme. Avec PORT=8080 dans l'environnement, la validation de configuration échoue (PORT n'est pas un entier). Ailleurs : « Nest can't resolve dependencies of … » sur un paramètre de constructeur, ou TypeORM qui refuse une colonne de type Object.

Cause. emitDecoratorMetadata ne reflète que les types écrits. Pour PORT = 3000 (type inféré), TypeScript émet design:type = Object, pas Number (vérifié avec TypeScript 6.0) : plainToInstance(..., { enableImplicitConversion: true }) ne sait pas qu'il faut un nombre et laisse "8080" en chaîne. Une interface, un import import type ou une union comme string | null sont aussi émis comme Object, qui ne désigne rien d'utile.

Correction.

  • Écrivez le type des propriétés décorées : PORT: number = 3000.
  • Injectez une interface par token (@Inject(PROJECTS_STORE)), et importez comme valeur (import { ProjectsService }) une classe injectée par son type.
  • Donnez le type de colonne explicitement dans @Column pour les propriétés nullables.
ts
// Every variable the app reads, with its type and default. Like a
// @ConfigurationProperties class validated with @Validated at startup.
// Write the types out (`PORT: number = 3000`): enableImplicitConversion reads
// the emitted design:type metadata, and an inferred type emits `Object`, so
// "8080" would stay a string.
class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment = Environment.Development;

  @IsInt()
  @Min(1)
  @Max(65535)
  PORT: number = 3000;

  @IsString()
  DB_HOST: string = 'localhost';

  @IsInt()
  DB_PORT: number = 5432;

  @IsString()
  DB_USER: string;

  @IsString()
  DB_PASSWORD: string;

  @IsString()
  DB_NAME: string;

  @IsString()
  @MinLength(8)
  JWT_SECRET: string;

  @Matches(/^\d+[smhd]$/)
  JWT_EXPIRES_IN: string = '15m';

  @IsIn(['typeorm', 'memory'])
  PROJECTS_STORE: 'typeorm' | 'memory' = 'typeorm';

  @IsOptional()
  @IsString()
  REDIS_URL?: string;
}

export function validateEnv(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated);
  if (errors.length > 0) {
    // Fail fast: the app refuses to start with a wrong configuration.
    throw new Error(`Invalid environment:\n${errors.map(String).join('\n')}`);
  }
  return validated;
}

Chapitres : J7, validation de l'environnement ; différences, design:type.

reflect-metadata dans les tests unitaires ​

Symptôme. Un test unitaire qui n'utilise pas Test.createTestingModule échoue avec Reflect.getMetadata is not a function.

Cause. Reflect.getMetadata vient du polyfill reflect-metadata, que Nest charge en démarrant. Un test qui appelle directement du code utilisant les métadonnées (ici class-transformer avec enableImplicitConversion) sans jamais charger Nest ne l'a pas.

Correction. Importez-le en tête du fichier de test, comme TaskFlow (ou ajoutez reflect-metadata aux setupFiles de Jest). Le script data-source.ts du CLI TypeORM, qui ne démarre pas Nest non plus, commence par le même import.

ts
// Nest loads reflect-metadata when it boots; a test that never boots Nest must
// load it itself, or enableImplicitConversion fails ("Reflect.getMetadata is
// not a function").
import 'reflect-metadata';
import { validateEnv } from './env.validation';

const valid = { DB_USER: 'taskflow', DB_PASSWORD: 'taskflow', DB_NAME: 'taskflow', JWT_SECRET: 'a-long-enough-secret' };

describe('validateEnv', () => {
  it('converts strings and fills defaults', () => {
    const env = validateEnv({ ...valid, PORT: '8080' });
    expect(env.PORT).toBe(8080);
    expect(env.JWT_EXPIRES_IN).toBe('15m');
    expect(env.PROJECTS_STORE).toBe('typeorm');
  });

  it('refuses to start with a missing or malformed variable', () => {
    expect(() => validateEnv({ ...valid, JWT_SECRET: undefined })).toThrow(/JWT_SECRET/);
    expect(() => validateEnv({ ...valid, JWT_EXPIRES_IN: 'soon' })).toThrow(/JWT_EXPIRES_IN/);
    expect(() => validateEnv({ ...valid, NODE_ENV: 'staging' })).toThrow(/NODE_ENV/);
  });
});

Chapitre : J10, Jest.

ConditionalModule avec une chaîne ​

Symptôme. Le module de file BullMQ se charge alors que REDIS_URL n'est pas défini : selon le code du module, l'application échoue au démarrage (TaskFlow lit REDIS_URL avec getOrThrow) ou tente de joindre un Redis absent.

Cause. ConditionalModule.registerWhen(Module, 'REDIS_URL') sous forme de chaîne vérifie seulement que la valeur n'est pas "false". Une variable absente passe le test.

Correction. Passez une fonction, qui reçoit l'environnement :

ts
@Module({
  imports: [
    // A function, not the string 'REDIS_URL': the string form only checks
    // that the value is not "false", so an unset variable would still load
    // the queue module and make it try to reach Redis.
    ConditionalModule.registerWhen(NotificationsQueueModule, (env) => Boolean(env.REDIS_URL)),
  ],
  providers: [NotificationsListener],
})
export class NotificationsModule {}

Côté consommateur, NotificationsListener reçoit la file avec @Optional() et se rabat sur un log quand elle n'existe pas.

Chapitre : J9, file de notifications.

BullMQ 6 sans ioredis ​

Symptôme. L'application s'arrête au chargement du module de file avec « BullMQ could not load the optional 'ioredis' package ».

Cause. BullMQ 6 déclare ioredis comme dépendance pair optionnelle (>=5.0.0) : npm ne l'installe pas avec lui.

Correction. Installez-le explicitement. TypeORM 1.1 déclare lui aussi ioredis en dépendance pair optionnelle, en ^5.0.4 : TaskFlow fixe donc une version 5, qui satisfait les deux.

bash
npm install --save-exact ioredis@5.11.1

Vérifié de bout en bout avec Redis : changement de statut → événement → listener → file → log de NotificationsProcessor.

Chapitre : J9, BullMQ.

Autres surprises vérifiées ​

  • Middleware sur toutes les routes : en Nest 11 et 12, écrivez forRoutes({ path: '{*splat}', method: RequestMethod.ALL }) (syntaxe path-to-regexp v8). Vérifié : l'en-tête x-correlation-id revient sur chaque réponse (J6).
  • Une colonne PostgreSQL date revient en chaîne 'YYYY-MM-DD', pas en Date (J5).
  • « Aujourd'hui » dépend du fuseau : à 1 h du matin à Paris en été, toISOString() donne encore la date de la veille (23 h UTC). TaskFlow calcule la date du jour avec localDate() et APP_TIME_ZONE, le fuseau du cron ; un test unitaire couvre ce cas (J9).
  • softDelete() laisse les lignes liées en place : les membres d'un projet supprimé restent dans project_members. Le contrôle d'accès de TaskFlow vérifie donc aussi que le projet existe, sinon ses anciens membres pourraient encore lire ou créer ses tâches (J8).
  • Le plugin Swagger ne lit que *.dto.ts et *.entity.ts : un DTO rangé ailleurs disparaît de la documentation sans message, d'où le nom pagination.dto.ts (J11).
  • ?archived=false est vrai si vous ne le convertissez pas : la chaîne "false" est truthy (J2).
  • Le throttler compte aussi les requêtes des tests : la suite e2e atteint la limite de 5 connexions par minute, d'où le test du 429 placé en dernier (J10).
  • Le cron tourne sur chaque instance : avec plusieurs réplicas, utilisez une file ou un verrou (J9).
  • Une sonde de liveness ne doit dépendre de rien : si /health/live vérifiait la base, une panne de PostgreSQL ferait redémarrer tous les pods (J11).