Qu’est-ce que TypeORM ?
TypeORM est un object-relational mapper pour TypeScript et JavaScript. Il vous permet de décrire votre base de données comme un ensemble de classes décorées de métadonnées, puis de l’interroger via des repositories ou un builder fluide. Présent depuis 2016, c’est l’ORM le plus étroitement associé à NestJS.
Sa particularité est de supporter simultanément deux patterns bien connus. En Active Record, une entité sait comment se charger et s’enregistrer elle-même. En Data Mapper, les entités sont des objets simples et un repository distinct gère la persistance. La plupart des équipes utilisent le Data Mapper pour le code applicatif et occasionnellement l’Active Record pour des modèles simples et autonomes.
Si vous connaissez déjà le SQL, le plus simple est de voir TypeORM comme une couche de mapping : les entités deviennent des tables, les décorateurs deviennent des définitions de colonnes, et chaque requête finit par devenir du SQL que vous auriez pu écrire à la main.
Entités : des classes qui deviennent des tables
Une entité est une classe marquée avec @Entity(). Chaque instance correspond à une ligne, et chaque propriété décorée correspond à une colonne.
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity("users")
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ unique: true })
email!: string;
@Column({ name: "display_name" })
displayName!: string;
}
L’argument passé à @Entity est le nom de la table. Si vous l’omettez, TypeORM en déduit un à partir du nom de la classe, mais être explicite permet d’éviter les surprises lors du renommage (refactoring). L’option name sur @Column permet de découpler la colonne de la base de données de la propriété TypeScript, c’est ainsi que displayName est mappé vers display_name.
Le ! après chaque propriété est l’assertion d’assignation définie (definite assignment assertion). TypeScript ne peut pas savoir que l’ORM remplit ces champs au moment de l’exécution, donc ! indique au compilateur de faire confiance à la base de données.
Les décorateurs de colonnes que vous utiliserez réellement
TypeORM déduit le type de la colonne à partir du type TypeScript de la propriété, mais cette inférence est limitée. Un string pourrait être un text, varchar ou char ; pour être précis, passez donc le type explicitement.
@Entity("posts")
export class Post {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: "text" })
body!: string;
@Column({ type: "integer", default: 0 })
views!: number;
@Column({ type: "boolean", default: false })
published!: boolean;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt!: Date;
}
Les décorateurs de colonnes générées valent la peine d’être mémorisés : @CreateDateColumn définit un horodatage à l’insertion, @UpdateDateColumn le rafraîchit à chaque mise à jour, et @DeleteDateColumn active le “soft delete” (suppression logique). @PrimaryGeneratedColumn("uuid") génère un UUID plutôt qu’un entier auto-incrémenté, ce qui est utile lorsque les IDs sont exposés publiquement.
Pour l’argent, stockez les unités mineures sous forme d’entiers dans une colonne integer ou bigint, et jamais en float. Pour les attributs réellement optionnels, une colonne jsonb avec { type: "jsonb" } convient, mais tout élément que vous filtrez ou contraignez doit posséder sa propre colonne.
Le DataSource et la configuration
Depuis la version 0.3, un DataSource est l’objet unique qui détient les options de connexion et distribue les repositories, managers et transactions.
import "reflect-metadata";
import { DataSource } from "typeorm";
export const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
entities: ["src/entities/*.entity.ts"],
migrations: ["src/migrations/*.ts"],
synchronize: false,
logging: ["error", "warn"],
});
L’import de reflect-metadata doit être effectué avant le chargement de toute entité, car les décorateurs en dépendent. Dans un build compilé, pointez entities et migrations vers les fichiers .js émis plutôt que vers les sources .ts.
Désactivez synchronize partout, sauf sur une base de données locale jetable. Cette option réécrit le schéma pour qu’il corresponde à vos entités au démarrage, ce qui signifie qu’une propriété renommée peut supprimer silencieusement une colonne et ses données. Les migrations existent précisément pour que les changements de schéma soient revus, versionnés et reproductibles.
Repositories et l’API find
Un repository est une passerelle typée vers une seule entité. Vous en obtenez un via DataSource et vous l’utilisez pour la majorité de vos lectures et écritures.
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
const recent = await users.find({
where: { email: ILike("%@example.com") },
order: { createdAt: "DESC" },
take: 20,
skip: 0,
});
find retourne un tableau et findOne retourne une seule ligne ou null. L’objet d’options accepte where, order, take, skip, select, relations et withDeleted. Les opérateurs tels que ILike, In, MoreThan et IsNull se trouvent dans le package typeorm et se composent à l’intérieur de where.
Les écritures utilisent create, save et remove :
const user = users.create({ email, displayName });
await users.save(user);
user.displayName = "Ada Lovelace";
await users.save(user);
save effectue un upsert : il insère lorsque la clé primaire est absente et met à jour lorsqu’elle est présente. Cette commodité masque si une ligne a été créée ou modifiée ; ainsi, lorsque cette distinction est importante, utilisez directement insert et update.
Les relations et leur chargement
Les relations sont déclarées des deux côtés. Le côté @ManyToOne possède la clé étrangère, et @OneToMany en est l’inverse.
@Entity("posts")
export class Post {
@ManyToOne(() => User, (user) => user.posts, { onDelete: "CASCADE" })
@JoinColumn({ name: "author_id" })
author!: User;
@Column({ name: "author_id" })
authorId!: string;
}
@Entity("users")
export class User {
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}
Conserver une colonne authorId explicite à côté de la relation est un pattern courant et utile : cela permet de lire et de filtrer par la clé étrangère sans charger la ligne associée.
@ManyToMany nécessite une table de jointure, déclarée avec @JoinTable sur le côté propriétaire. @OneToOne fonctionne comme @ManyToOne mais impose l’unicité.
Le chargement est l’aspect crucial. Par défaut, les relations ne sont pas chargées. Vous devez le demander via l’option relations, un flag eager, ou une jointure via le QueryBuilder.
const posts = await AppDataSource.getRepository(Post).find({
relations: { author: true },
where: { published: true },
});
Les relations eager sont chargées automatiquement à chaque find, tandis que les relations lazy retournent une promesse qui déclenche une requête lors de l’accès. Les deux sont pratiques, mais les deux masquent le nombre de requêtes effectuées. Privilégiez le chargement explicite, surtout sur les chemins critiques (hot paths), afin que le coût d’une requête soit visible là où elle est écrite.
Le QueryBuilder pour les requêtes complexes
Le QueryBuilder permet de gérer les jointures, les agrégations et les filtres dynamiques que l’API find ne peut pas exprimer proprement.
const posts = await AppDataSource.getRepository(Post)
.createQueryBuilder("post")
.innerJoinAndSelect("post.author", "author")
.where("author.email = :email", { email })
.andWhere("post.createdAt >= :since", { since })
.orderBy("post.createdAt", "DESC")
.take(20)
.getMany();
Les alias sont obligatoires et les paramètres sont toujours liés via des placeholders :name, jamais par interpolation de chaînes. getMany retourne des entités, getRawMany retourne des lignes brutes, et getOne ou getManyAndCount couvrent les cas courants de ligne unique et de pagination.
Les filtres dynamiques se construisent naturellement :
const qb = repo.createQueryBuilder("post").where("post.published = true");
if (tag) {
qb.andWhere("post.tags @> :tag", { tag: [tag] });
}
if (search) {
qb.andWhere("post.title ILIKE :search", { search: `%${search}%` });
}
Lorsqu’une requête est trop spécifique pour le builder, dataSource.query() exécute du SQL paramétré directement et retourne des lignes brutes.
Migrations : génération et exécution
Les migrations sont le seul moyen sécurisé de modifier un schéma en production. TypeORM peut les générer en comparant vos entités à la base de données active.
npx typeorm migration:generate src/migrations/CreateUsers -d src/data-source.ts
npx typeorm migration:run -d src/data-source.ts
npx typeorm migration:revert -d src/data-source.ts
Le fichier généré contient les méthodes up et down. Lisez-les avant de les commiter : le diff est une estimation et produit occasionnellement des instructions destructives que vous n’aviez pas prévues.
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateUsers1710000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE users`);
}
}
Exécutez les migrations comme une étape de déploiement dédiée avec un rôle capable d’effectuer du DDL, distinct du rôle utilisé par l’application lors de l’exécution. Ne laissez jamais l’application modifier son propre schéma au démarrage.
Transactions
Une transaction regroupe des instructions afin qu’elles réussissent toutes ou qu’elles soient toutes annulées (rollback). L’assistant DataSource.transaction gère pour vous le commit, le rollback et la connexion.
await AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, { email, displayName });
await manager.save(Post, { title, body, author: user });
});
Si la fonction de rappel (callback) lève une exception, TypeORM effectue un rollback et relance l’erreur. Utilisez le manager fourni pour chaque instruction à l’intérieur du bloc ; un repository obtenu directement depuis le DataSource utiliserait une connexion différente et s’exécuterait en dehors de la transaction.
Pour un contrôle plus précis, createQueryRunner expose startTransaction, commitTransaction et rollbackTransaction. Cela est utile lorsque la transaction s’étend sur du code qui ne peut pas être exprimé par un seul callback, mais veillez à garder ces transactions courtes : maintenir une transaction ouverte pendant un appel HTTP bloque le vacuum et favorise les conflits de verrouillage (lock contention).
Éviter le problème du N+1
Le problème du N+1 est le bug de performance le plus courant dans le code utilisant un ORM. Vous chargez une liste d’articles, puis vous bouclez dessus pour accéder à post.author, ce qui génère une requête pour la liste, plus une requête supplémentaire par article.
// N+1: one query for posts, then one per author.
const posts = await repo.find();
for (const post of posts) {
console.log(post.author.email);
}
La solution consiste à charger la relation dès le départ en une seule requête :
const posts = await repo.find({ relations: { author: true } });
Lorsque vous avez seulement besoin d’un décompte, évitez tout simplement de charger les enfants :
const users = await AppDataSource.getRepository(User)
.createQueryBuilder("user")
.loadRelationCountAndMap("user.postCount", "user.posts")
.getMany();
Activez la journalisation des requêtes (query logging) en développement et consultez les logs après avoir généré une page. Si une requête émet des dizaines de requêtes quasi identiques, vous faites face à un problème de N+1 ; l’option relations ou une jointure est alors la solution.
TypeORM dans NestJS
@nestjs/typeorm intègre l’ORM avec l’injection de dépendances de Nest. Le module racine gère la connexion, et les modules de fonctionnalités demandent les repositories.
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false,
}),
TypeOrmModule.forFeature([User, Post]),
],
})
export class AppModule {}
Un service reçoit ensuite son repository via le constructeur :
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findByEmail(email: string) {
return this.users.findOneBy({ email });
}
}
autoLoadEntities enregistre les entités qui ont été passées à forFeature, ce qui permet d’éviter une longue liste d’imports dans la configuration racine. Gérez vos migrations via la CLI de Nest ou un script autonome plutôt qu’au démarrage de l’application.
Active Record ou Data Mapper ?
Le choix repose principalement sur l’endroit où réside la persistance. Active Record est plus concis pour les modèles simples, car l’entité transporte ses propres méthodes de requête.
@Entity("users")
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
email!: string;
}
const user = await User.findOneBy({ id });
Data Mapper conserve les entités comme de simples données et déplace toute la persistance vers des repositories :
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
Active Record est pratique pour les scripts et les petits services, mais il couple le modèle à l’ORM et rend les tests plus complexes. Data Mapper est le meilleur choix par défaut pour le code applicatif, et c’est d’ailleurs ce que NestJS encourage. Choisissez un seul style par projet plutôt que de les mélanger, car la cohérence est plus importante que le choix technique lui-même.
Bonnes pratiques
- Désactivez
synchronizeet gérez chaque modification de schéma via une migration revue. - Chargez les relations explicitement avec
relationsou une jointure via QueryBuilder, et activez le log des requêtes en développement. - Utilisez
@Column({ type: ... })dès que le type inféré pourrait être incorrect. - Conservez la colonne de clé étrangère
authorIdà côté de la relation pour faciliter le filtrage. - Privilégiez les dépôts Data Mapper pour le code applicatif et réservez Active Record pour les scripts.
- Utilisez
dataSource.transactionet passez systématiquement la transactionmanagerdans les appels imbriqués. - Liez chaque valeur en tant que paramètre ; n’interpolez jamais des entrées utilisateur directement dans le SQL.
- Ajoutez des index sur les colonnes utilisées pour le filtrage et les jointures, et vérifiez-les avec
EXPLAIN ANALYZE. - Exécutez les migrations avec un rôle DDL dédié, distinct du rôle d’exécution de l’application.
Erreurs courantes
- Laisser
synchronize: trueactivé sur une base de données partagée ou de production. - Accéder à une relation lazy à l’intérieur d’une boucle, provoquant une tempête de requêtes N+1.
- Oublier l’import de
reflect-metadata, ce qui casse les métadonnées des décorateurs au runtime. - Utiliser le repository
DataSourceà l’intérieur d’une transaction au lieu du manager fourni. - Supposer que la migration générée est toujours sûre ; elle peut supprimer des colonnes lors d’un renommage.
- Typer une colonne en
stringet laisser TypeORM inférervarchar(255)de manière inattendue. - Stocker de l’argent sous forme de float et perdre en précision.
- Traiter
findOnecomme s’il levait une exception ; il retournenulllorsqu’aucun résultat ne correspond. - Mélanger les patterns Active Record et Data Mapper au sein d’une même entité.
Et après ?
TypeORM est un choix naturel si vous travaillez avec NestJS, et bien le comprendre permet de mieux évaluer les alternatives. Consultez le guide Prisma pour découvrir un ORM “schema-first” avec un client généré, ou Drizzle ORM si vous préférez une couche légère qui place le SQL au premier plan. Pour approfondir vos connaissances sur la base de données elle-même, le guide PostgreSQL traite des index, des transactions et du planner, tandis que le guide NestJS explique comment structurer les services qui encapsulent vos repositories.