TypeScript ORM

TypeORM

TypeORM mappe les classes TypeScript vers des tables de base de données à l'aide de décorateurs. Il supporte les patterns Data Mapper et Active Record, et fournit des repositories, un QueryBuilder et la gestion des migrations dans un seul package.

intermediate15 min readUpdated 16 sept. 2026
user.entity.ts
ts
// user.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  CreateDateColumn,
} from "typeorm";
import { Post } from "./post.entity";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

  @Column({ unique: true })
  email!: string;

  @Column({ name: "display_name" })
  displayName!: string;

  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[];
}
Sortie
2016
Langage
TypeScript / JavaScript
Patterns
Data Mapper et Active Record
Bases de données
Postgres, MySQL, SQLite, MSSQL, Oracle
Style
Entités basées sur des décorateurs
Version actuelle
0.3.x

Pourquoi c'est important

Ce que TypeORM apporte à un backend TypeScript

Entités pilotées par décorateurs

Les classes et les décorateurs de propriétés décrivent les tables et les colonnes, ainsi le schéma réside dans un code typé que le compilateur peut vérifier.

Relations de premier ordre

@ManyToOne, @OneToMany et @ManyToMany mappent les clés étrangères et les tables de jointure, avec un chargement eager ou explicite selon vos besoins.

Migrations depuis les entités

Générez des fichiers de migration en comparant les entités avec la base de données, puis exécutez-les dans l'ordre sur chaque environnement.

Le tableau complet

Trois concepts fondamentaux de toute application TypeORM

Les entités décrivent les tables, un DataSource gère la connexion, et les repositories ou le QueryBuilder lisent et écrivent les lignes.

Entités

Modèle

Une classe décorée correspond à une table, et chaque propriété décorée correspond à une colonne ou une relation.

DataSource

Connexion

Un objet unique détient les options de connexion et crée les entity managers, les repositories et les transactions.

Repositories

Requête

Les repositories typés offrent find, findOne et save, tandis que le QueryBuilder couvre tout ce qu'ils ne peuvent exprimer.

HTML5 en un coup d'oeil

La boîte à outils TypeORM

Décorateurs de colonnes

@Column, @PrimaryGeneratedColumn, @CreateDateColumn et consorts définissent la structure de la table.

Relations

@ManyToOne, @OneToMany, @OneToOne et @ManyToMany relient les tables entre elles.

API Repository

find, findOne, save, remove, count et findAndCount avec des clauses where typées.

QueryBuilder

Chaînez select, join, where et orderBy, ou passez au SQL brut quand c'est nécessaire.

Migrations

migration:generate écrit le SQL à partir des diffs d'entités ; migration:run l'applique.

Transactions

dataSource.transaction et QueryRunner encapsulent des écritures multi-étapes de manière atomique.

Modèle de données

Un utilisateur et ses articles

L'entité User possède plusieurs lignes Post, et chaque Post pointe vers un auteur via une clé étrangère.

Les tables users et postsEntités TypeORM
  • iduuidClé primaire générée, exposée comme une string en TypeScript
  • emailtextColonne unique, appliquée par une contrainte de base de données
  • displayNametextMappée vers la colonne display_name
  • createdAttimestamptzDéfinie automatiquement par @CreateDateColumn
  • postsPost[]Relation one-to-many, chargée uniquement sur demande

L'entité User possède plusieurs lignes Post, et chaque Post pointe vers un auteur via une clé étrangère.

Un bref aperçu

Des décorateurs au DataSource

  1. 2016

    Sortie de TypeORM

    Un ORM basé sur les décorateurs pour TypeScript arrive, visant à s'intégrer naturellement dans une base de code typée.

    16
  2. 2018

    L'ère NestJS

    @nestjs/typeorm fait de TypeORM la couche de données par défaut pour toute une génération d'applications Nest.

    18
  3. 2020

    Maturation des migrations et subscribers

    La comparaison de schémas, les entity subscribers et les listeners de cycle de vie complètent le framework.

    20
  4. 2022

    Version 0.3 et le DataSource

    createConnection est remplacé par un DataSource explicite qui gère la connexion et ses options.

    22
  5. 2024

    Un ORM mature et largement déployé

    TypeORM reste courant dans les backends NestJS, même si Drizzle et Prisma attirent de nouveaux projets.

    24

Le guide complet

TypeORM: Tout ce que vous devez savoir

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 synchronize et gérez chaque modification de schéma via une migration revue.
  • Chargez les relations explicitement avec relations ou 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.transaction et passez systématiquement la transaction manager dans 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: true activé 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 string et laisser TypeORM inférer varchar(255) de manière inattendue.
  • Stocker de l’argent sous forme de float et perdre en précision.
  • Traiter findOne comme s’il levait une exception ; il retourne null lorsqu’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.

En pratique

Entités, repositories, QueryBuilder, transactions

Les quatre couches que vous utilisez quotidiennement, de la définition de la classe jusqu'à l'écriture atomique.

src/entities/post.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
} from "typeorm";
import { User } from "./user.entity";

@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @Column({ type: "text" })
  body!: string;

  @ManyToOne(() => User, (user) => user.posts, {
    onDelete: "CASCADE",
  })
  @JoinColumn({ name: "author_id" })
  author!: User;

  @Column({ name: "author_id" })
  authorId!: string;

  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;
}

Méthodes Repository vs QueryBuilder

Commencez par les repositories car ils sont typés et difficiles à utiliser incorrectement. Utilisez le QueryBuilder lorsqu'une requête nécessite des jointures, des agrégations ou des filtres dynamiques.

Repository
const users = await repo.find({
  where: { email: "[email protected]" },
  order: { createdAt: "DESC" },
  take: 10,
});
QueryBuilder
const users = await repo
  .createQueryBuilder("user")
  .leftJoinAndSelect("user.posts", "post")
  .where("user.email = :email", { email })
  .orderBy("user.createdAt", "DESC")
  .take(10)
  .getMany();

Relations Eager vs chargement explicite

Le chargement eager est pratique mais s'exécute à chaque find et peut multiplier silencieusement les requêtes. Les relations explicites rendent le coût visible à l'endroit de l'appel.

Préférer
// Explicit: one clear extra join, chosen per query.
const posts = await repo.find({
  relations: { author: true },
  where: { status: "published" },
});
Éviter
// Eager on the entity: loads on every single find,
// including the ones that never need the author.
@ManyToOne(() => User, { eager: true })
author!: User;

Compromis

Devriez-vous choisir TypeORM pour un nouveau projet ?

TypeORM est performant et familier pour les équipes NestJS, mais l'écosystème a évolué par endroits. Pesez l'ergonomie face aux alternatives.

Strengths

  • Familier pour les équipes NestJS

    @nestjs/typeorm intègre les repositories dans l'injecteur de dépendances, rendant les entités et services semblables à du code Nest natif.

  • Deux patterns ORM dans une seule librairie

    Vous pouvez utiliser les repositories Data Mapper pour la majorité du code et Active Record pour les modèles simples, sans adopter un second outil.

  • Relations et migrations riches

    Quatre types de relations, cascades, subscribers et migrations par diff de schéma couvrent un large éventail de modélisation relationnelle.

Trade-offs

  • Chargement silencieux des relations

    Les relations lazy et eager facilitent le déclenchement de requêtes non intentionnelles, ce qui cause les problèmes N+1 en production.

  • Typage parfois imprécis

    Les clauses where de find et le QueryBuilder acceptent des strings, donc certaines erreurs n'apparaissent qu'à l'exécution plutôt qu'à la compilation.

  • Maintenance stable, mais pas rapide

    Le développement est plus lent que pour Prisma ou Drizzle, et certains tickets peuvent rester ouverts longtemps. Vérifiez le repository avant un engagement à long terme.

FAQ

Foire aux questions

Keep learning

Related topics from the roadmap.

$ commencer à apprendre

Prêt à apprendre TypeORM ?

Notre tutoriel interactif vous guide à travers TypeORM pas à pas — avec des quiz et du vrai code que vous pouvez exécuter dans le navigateur.