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ège | Ce que vous voyez | Chapitre |
|---|---|---|
await oublié | Erreur perdue, réponse envoyée avant la fin de l'écriture | J1 |
synchronize: true en production | Colonne supprimée avec ses données | J4 |
| Entités renvoyées brutes | passwordHash dans le JSON | J8 |
| Dépendance circulaire | L'application ne démarre pas | J3 |
| État mutable dans un singleton | Données d'un utilisateur chez un autre, sous charge | J3 |
| Typage sans validation | Données invalides acceptées | J2 |
Égalité et this | Comparaison fausse, this vaut undefined | J1 |
@Res() dans un contrôleur | Sérialisation ignorée, requête qui ne répond pas | J2 |
Métadonnées de type réduites à Object | "8080" reste une chaîne, dépendance introuvable | J7 |
reflect-metadata dans les tests | Reflect.getMetadata is not a function | J10 |
ConditionalModule et chaîne | Module chargé alors que la variable est absente | J9 |
BullMQ 6 sans ioredis | Crash au chargement de la file | J9 |
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
awaitdevant chaque appel asynchrone, et remplacezforEachparfor … of(séquentiel) ouPromise.all(items.map(…))(concurrent). - Activez la règle de lint qui détecte les promesses flottantes :
@typescript-eslint/no-floating-promisesavec ESLint,typescript/no-floating-promisesavec oxlint (configuration par défaut du starter Nest 12, vérifiée : unsave();sansawaitest une erreur). - Quand ne pas attendre est voulu, dites-le avec
void:
// 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 :
// 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.
@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émettask.status.changedetNotificationsListenerl'écoute, sans import entre les deux modules ; - n'exportez que ce dont l'autre module a besoin (
ProjectsModuleexporteProjectsService, 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.
// 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 == ''etnull == undefinedsont vrais.===compare les objets par référence, comme==en Java, et il n'existe pas deequals(): deuxDateé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.thisdé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,thisvautundefined.
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
typede colonne explicitement dans@Columnpour les propriétés nullables.
// 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.
// 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 :
@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.
npm install --save-exact ioredis@5.11.1Vé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êtex-correlation-idrevient sur chaque réponse (J6). - Une colonne PostgreSQL
daterevient en chaîne'YYYY-MM-DD', pas enDate(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 aveclocalDate()etAPP_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 dansproject_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.tset*.entity.ts: un DTO rangé ailleurs disparaît de la documentation sans message, d'où le nompagination.dto.ts(J11). ?archived=falseest 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/livevérifiait la base, une panne de PostgreSQL ferait redémarrer tous les pods (J11).