Aller au contenu
Sylvain Joffraud l4w
Retour

Enums TypeScript et PostgreSQL : un test de parité sans base

Un enum Prisma PostgreSQL désynchronisé ne casse rien au build : dans l’API d’une billetterie événementielle, l’enum TypeScript CommissionStatus déclarait cancelled depuis l’origine, alors que le type PostgreSQL ne l’a jamais contenu. Le premier remboursement aurait levé une erreur en production. La cause est architecturale : les migrations sont du SQL écrit à la main et Prisma ne sert que de client typé, donc rien ne vérifie que le code et la base parlent des mêmes valeurs. Le correctif tient en un test Jest qui lit schema.prisma et compare les enums valeur par valeur, sans connexion à la base.

Pourquoi l’enum Prisma et l’enum PostgreSQL se désynchronisent ?

L’API tourne sous NestJS 10.4, TypeScript 5.8, Prisma 5.22 et PostgreSQL. Les migrations vivent dans un dossier de fichiers .sql, rejoués par un petit script Node qui utilise le driver pg et note chaque fichier appliqué dans une table de suivi. Prisma n’y est pas un outil de migration : schema.prisma est tenu à la main pour générer le client typé.

Une valeur de statut existe donc en trois exemplaires :

CopieOùQui la vérifie
Type PostgreSQL commission_statusmigration SQLla base, à l’écriture
Bloc enum CommissionStatusschema.prismale client Prisma généré
enum CommissionStatus TypeScriptcode métierpersonne

Le schema drift (dérive de schéma) désigne l’écart entre ce que le code croit de la base et ce que la base contient réellement. Ce projet l’avait déjà vécu au printemps 2026 : douze tables décrites dans schema.prisma manquaient en base. Le même jour, quatre colonnes de statut texte avaient été converties en enums PostgreSQL, sans que les enums TypeScript soient relus.

Trois dérives, aucune détectable sans base

En instruisant le cas de cancelled, j’ai trouvé deux autres écarts de la même famille :

  1. Une valeur TypeScript absente de PostgreSQL : CommissionStatus.CANCELLED. L’écriture aurait échoué au premier remboursement ou à la première annulation d’événement ; si la valeur atteint la base, PostgreSQL répond invalid input value for enum commission_status: "cancelled".
  2. Une valeur PostgreSQL absente de TypeScript : disputed. Une commission litigieuse relue par le code ne correspondait à aucune valeur connue de l’enum.
  3. Un enum TypeScript entièrement faux : PayoutStatus déclarait pending et paid, deux valeurs que le type payout_status n’a jamais acceptées, et ignorait requested, la valeur que le service de versement écrit réellement (en chaîne littérale, pas via l’enum).

Aucun de ces écarts ne produit d’erreur de compilation. Le troisième survivait parce que personne n’utilisait l’enum : du code mort, mais faux, sur le chemin de sortie de l’argent.

Le correctif : un test de parité enum qui lit schema.prisma

Côté base, une migration rejouable ajoute la valeur manquante :

ALTER TYPE public.commission_status ADD VALUE IF NOT EXISTS 'cancelled';

⚠️ Depuis PostgreSQL 12, ALTER TYPE … ADD VALUE est accepté dans une transaction, mais la nouvelle valeur n’est utilisable qu’après le commit ; jusqu’à PostgreSQL 11, la commande était refusée dans un bloc de transaction (doc ALTER TYPE). Une migration qui ajoute la valeur puis l’écrit, exécutée comme une seule transaction, échoue.

Côté code, disputed rejoint l’enum TypeScript, et PayoutStatus est réaligné sur la base. Retirer pending du type PostgreSQL n’était pas une option : un enum PostgreSQL ne permet pas de supprimer une valeur sans recréer le type.

Puis le garde-fou, un test Jest 29 (version simplifiée) :

import { readFileSync } from 'fs';
import { join } from 'path';
import { CommissionStatus } from './commission-status.enum';
import { PayoutStatus } from './payout-status.enum';

function prismaEnumValues(name: string): string[] {
  const schema = readFileSync(join(__dirname, '../../../prisma/schema.prisma'), 'utf8');
  const match = new RegExp(`enum ${name} \\{([^}]*)\\}`).exec(schema);
  if (!match) throw new Error(`Enum ${name} introuvable dans schema.prisma`);
  return match[1]
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line.length > 0 && !line.startsWith('@@'));
}

describe('Parité des enums TypeScript / PostgreSQL', () => {
  it.each([
    ['CommissionStatus', Object.values(CommissionStatus)],
    ['PayoutStatus', Object.values(PayoutStatus)],
  ])('%s a les mêmes valeurs des deux côtés', (name, values) => {
    expect([...values].sort()).toEqual(prismaEnumValues(name).sort());
  });
});

Le test compare des ensembles triés : une valeur en trop ou en moins de chaque côté le fait échouer, avec un diff Jest qui nomme la valeur fautive. Il aurait attrapé les trois dérives.

Un enum dupliqué entre le code et la base n’est fiable que si un test compare les deux copies ; sans ce test, la première écriture en production s’en charge.

Ce que le test ne couvre pas

Le test suppose que schema.prisma reflète la base. C’est une convention, pas une garantie : chaque migration SQL doit modifier schema.prisma dans le même commit. Vérifier schema.prisma contre la base réelle demande une connexion (prisma db pull par exemple), donc un autre type de contrôle. Le même problème d’enum comme source de vérité existe côté PHP, avec les enums backed de PHP 8.1 ; ici la difficulté vient du fait que la vérité est répartie entre deux langages.

Lessons learned

Questions fréquentes

Pourquoi mon enum TypeScript et mon enum PostgreSQL divergent-ils alors que j’utilise Prisma ?
Prisma ne synchronise rien si les migrations sont écrites à la main en SQL et que Prisma sert seulement de client typé. L’enum TypeScript du code métier, le bloc enum de schema.prisma et le type PostgreSQL sont alors trois copies maintenues séparément. Rien n’échoue à la compilation quand l’une d’elles bouge : la divergence apparaît au premier INSERT ou UPDATE qui écrit la valeur manquante.
Comment ajouter une valeur à un enum PostgreSQL existant ?
La commande est ALTER TYPE commission_status ADD VALUE IF NOT EXISTS 'cancelled';. La clause IF NOT EXISTS rend la migration rejouable : si la valeur existe déjà, PostgreSQL émet une notice au lieu d’une erreur. Depuis PostgreSQL 12, la commande est acceptée dans une transaction, mais la nouvelle valeur n’est utilisable qu’après le commit. Jusqu’à PostgreSQL 11, elle était refusée dans un bloc de transaction.
Peut-on supprimer une valeur d’un enum PostgreSQL ?
Non, pas directement. La documentation PostgreSQL indique qu’une valeur existante ne peut pas être retirée d’un type enum, ni son ordre de tri modifié, sans supprimer et recréer le type. Corriger un enum TypeScript qui annonce des valeurs inconnues de la base se fait donc côté code, en retirant ces valeurs de l’enum TypeScript plutôt qu’en touchant au type PostgreSQL.
Un test de parité d’enums a-t-il besoin d’une base de données ?
Non. Un test Jest peut lire schema.prisma comme un fichier texte, extraire les valeurs du bloc enum par expression régulière et les comparer à Object.values() de l’enum TypeScript. Le test tourne en quelques millisecondes dans la CI, sans conteneur PostgreSQL, à condition que schema.prisma soit mis à jour dans le même commit que chaque migration SQL.

Partager cet article :

Article précédent
`_acme-challenge` oublié : le wildcard DNS ne répond plus
Article suivant
WireGuard full-tunnel : Docker 28 bloque le retour