¿Qué es TypeORM?
TypeORM es un object-relational mapper para TypeScript y JavaScript. Te permite describir tu base de datos como un conjunto de clases decoradas con metadatos y, posteriormente, realizar consultas a través de repositorios o un constructor fluido. Existe desde 2016 y es el ORM más asociado con NestJS.
Su característica distintiva es que soporta dos patrones muy conocidos simultáneamente. En Active Record, una entidad sabe cómo cargarse y guardarse a sí misma. En Data Mapper, las entidades son objetos simples y un repositorio independiente se encarga de la persistencia. La mayoría de los equipos utilizan Data Mapper para el código de la aplicación y, ocasionalmente, Active Record para modelos pequeños y autónomos.
Si ya conoces SQL, la mejor forma de entender TypeORM es como una capa de mapeo: las entidades se convierten en tablas, los decoradores en definiciones de columnas y cada consulta termina siendo un SQL que podrías haber escrito a mano.
Entidades: clases que se convierten en tablas
Una entidad es una clase marcada con @Entity(). Cada instancia es una fila y cada propiedad decorada es una columna.
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;
}
El argumento de @Entity es el nombre de la tabla. Si lo omites, TypeORM deriva uno a partir del nombre de la clase, pero ser explícito evita sorpresas cuando se refactorizan los nombres. La opción name en @Column desacopla la columna de la base de datos de la propiedad de TypeScript, que es como displayName se mapea a display_name.
El ! después de cada propiedad es la aserción de asignación definitiva. TypeScript no puede detectar que el ORM rellena estos campos en tiempo de ejecución, por lo que ! le indica al compilador que confíe en la base de datos.
Decoradores de columna que realmente usarás
TypeORM infiere el tipo de columna a partir del tipo de TypeScript de la propiedad, pero la inferencia es limitada. Un string podría ser text, varchar o char, por lo que para cualquier cosa precisa, pasa el tipo explícitamente.
@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;
}
Vale la pena memorizar los decoradores de columnas generadas: @CreateDateColumn establece una marca de tiempo al insertar, @UpdateDateColumn la actualiza en cada modificación y @DeleteDateColumn habilita los borrados lógicos (soft deletes). @PrimaryGeneratedColumn("uuid") genera un UUID en lugar de un entero autoincremental, lo cual es útil cuando los IDs se exponen públicamente.
Para el dinero, almacena las unidades menores como enteros en una columna integer o bigint y nunca uses un float. Para atributos que sean genuinamente opcionales, una columna jsonb con { type: "jsonb" } está bien, pero cualquier cosa que necesites filtrar o restringir debe ir en su propia columna.
El DataSource y la configuración
Desde la versión 0.3, un DataSource es el objeto único que contiene las opciones de conexión y distribuye los repositorios, managers y transacciones.
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"],
});
La importación de reflect-metadata debe realizarse antes de cargar cualquier entidad, ya que los decoradores dependen de ella. En una compilación final, apunta entities y migrations a los archivos .js generados en lugar de las fuentes .ts.
Mantén synchronize desactivado en todas partes, excepto en una base de datos local temporal. Esta opción reescribe el esquema para que coincida con tus entidades al iniciar, lo que significa que una propiedad renombrada puede eliminar silenciosamente una columna y sus datos. Las migraciones existen precisamente para que los cambios de esquema sean revisados, versionados y repetibles.
Repositorios y la API find
Un repositorio es un gateway tipado para una entidad. Obtienes uno desde DataSource y lo utilizas para la mayoría de tus lecturas y escrituras.
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 devuelve un array y findOne devuelve una sola fila o null. El objeto de opciones acepta where, order, take, skip, select, relations y withDeleted. Los operadores como ILike, In, MoreThan y IsNull se encuentran en el paquete typeorm y se componen dentro de where.
Las escrituras utilizan create, save y remove:
const user = users.create({ email, displayName });
await users.save(user);
user.displayName = "Ada Lovelace";
await users.save(user);
save realiza un upsert: inserta cuando la clave primaria está ausente y actualiza cuando está presente. Esa conveniencia oculta si una fila fue creada o modificada, por lo que cuando la distinción sea importante, utiliza insert y update directamente.
Relaciones y cómo se cargan
Las relaciones se declaran en ambos lados. El lado @ManyToOne posee la foreign key, y @OneToMany es su inverso.
@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[];
}
Mantener una columna authorId explícita junto a la relación es un patrón común y útil: permite leer y filtrar por la foreign key sin necesidad de cargar la fila relacionada.
@ManyToMany requiere una tabla de unión, declarada con @JoinTable en el lado propietario. @OneToOne funciona como @ManyToOne pero impone unicidad.
La carga es la parte fundamental. Por defecto, las relaciones no se cargan. Debes solicitarlo mediante la opción relations, un flag de eager loading o un join de QueryBuilder.
const posts = await AppDataSource.getRepository(Post).find({
relations: { author: true },
where: { published: true },
});
Las relaciones eager se cargan automáticamente en cada find, y las relaciones lazy devuelven una promesa que dispara una consulta al acceder a ellas. Ambas son convenientes, pero ambas ocultan el número de consultas. Es preferible la carga explícita, especialmente en rutas críticas (hot paths), para que el coste de una consulta sea visible donde se escribe.
El QueryBuilder para consultas reales
El QueryBuilder cubre joins, agregados y filtros dinámicos que la API de find no puede expresar de manera limpia.
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();
Los alias son obligatorios y los parámetros siempre se vinculan con marcadores de posición :name, nunca mediante interpolación de strings. getMany devuelve entidades, getRawMany devuelve filas simples, y getOne o getManyAndCount cubren los casos comunes de fila única y paginación.
Los filtros dinámicos se construyen de forma natural:
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}%` });
}
Cuando una consulta es demasiado especializada para el builder, dataSource.query() ejecuta SQL parametrizado directamente y devuelve filas raw.
Migraciones: generación y ejecución
Las migraciones son la única forma segura de modificar un esquema en producción. TypeORM puede generarlas comparando tus entidades con la base de datos activa.
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
El archivo generado contiene los métodos up y down. Léelos antes de hacer el commit: el diff es una estimación y, ocasionalmente, puede generar sentencias destructivas que no tenías previstas.
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`);
}
}
Ejecuta las migraciones como un paso de despliegue dedicado con un rol que pueda realizar DDL, independiente del rol que utiliza la aplicación en tiempo de ejecución. Nunca permitas que la aplicación altere su propio esquema al iniciar.
Transacciones
Una transacción agrupa sentencias para que todas tengan éxito o todas se reviertan (roll back). El helper DataSource.transaction gestiona el commit, el rollback y la conexión por ti.
await AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, { email, displayName });
await manager.save(Post, { title, body, author: user });
});
Si el callback lanza una excepción, TypeORM realiza el rollback y vuelve a lanzar el error. Utiliza el manager proporcionado para cada sentencia dentro del bloque; un repositorio obtenido directamente desde el DataSource utiliza una conexión diferente y se ejecutaría fuera de la transacción.
Para un control más preciso, createQueryRunner expone startTransaction, commitTransaction y rollbackTransaction. Esto es útil cuando la transacción abarca código que no puede expresarse como un único callback, pero mantén dichas transacciones cortas: mantener una abierta durante una llamada HTTP bloquea el vacuum y provoca contención de bloqueos (lock contention).
Evitando el problema N+1
El problema N+1 es el error de rendimiento más común en el código que utiliza un ORM. Ocurre cuando cargas una lista de posts, luego iteras sobre ella y accedes a post.author, lo que genera una consulta para la lista más una consulta adicional por cada post.
// 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 solución es cargar la relación por adelantado en una sola consulta:
const posts = await repo.find({ relations: { author: true } });
Cuando solo necesites un conteo, evita cargar los hijos por completo:
const users = await AppDataSource.getRepository(User)
.createQueryBuilder("user")
.loadRelationCountAndMap("user.postCount", "user.posts")
.getMany();
Activa el registro de consultas (query logging) en desarrollo y revisa el log después de cargar una página. Si una solicitud ejecuta docenas de consultas casi idénticas, tienes un problema N+1 y la opción relations o un join es la cura.
TypeORM en NestJS
@nestjs/typeorm integra el ORM con la inyección de dependencias de Nest. El módulo raíz gestiona la conexión y los módulos de funcionalidades solicitan los repositorios.
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false,
}),
TypeOrmModule.forFeature([User, Post]),
],
})
export class AppModule {}
Posteriormente, un servicio recibe su repositorio a través del constructor:
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findByEmail(email: string) {
return this.users.findOneBy({ email });
}
}
autoLoadEntities registra las entidades que fueron pasadas a forFeature, lo que evita que la configuración raíz tenga una lista extensa de imports. Mantén las migraciones en la CLI de Nest o en un script independiente en lugar de ejecutarlas durante el inicio de la aplicación.
¿Active Record o Data Mapper?
La elección depende principalmente de dónde resida la persistencia. Active Record es más conciso para modelos simples, ya que la entidad lleva sus propios métodos de consulta.
@Entity("users")
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
email!: string;
}
const user = await User.findOneBy({ id });
Data Mapper mantiene las entidades como datos puros y traslada toda la persistencia a los repositorios:
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
Active Record es conveniente en scripts y servicios pequeños, pero acopla el modelo al ORM y dificulta las pruebas. Data Mapper es la mejor opción por defecto para el código de la aplicación, y es lo que NestJS recomienda. Elige un único estilo por proyecto en lugar de mezclarlos, ya que la consistencia es más importante que la elección específica.
Mejores prácticas
- Mantén
synchronizedesactivado y gestiona cada cambio de esquema mediante una migración revisada. - Carga las relaciones explícitamente con
relationso un join de QueryBuilder, y activa el log de queries en desarrollo. - Usa
@Column({ type: ... })en cualquier caso donde el tipo inferido pueda ser incorrecto. - Mantén la columna de clave foránea de
authorIdjunto a la relación para realizar filtrados más eficientes. - Prefiere repositorios Data Mapper para el código de la aplicación y reserva Active Record para scripts.
- Usa
dataSource.transactiony pasa siempre la transacciónmanageren las llamadas anidadas. - Vincula cada valor como un parámetro; nunca interpoles la entrada del usuario directamente en el SQL.
- Añade índices a las columnas que utilices para filtrar y hacer joins, y confírmalos con
EXPLAIN ANALYZE. - Ejecuta las migraciones con un rol DDL dedicado, separado del rol de ejecución de la aplicación.
Errores comunes
- Dejar
synchronize: trueen una base de datos compartida o de producción. - Acceder a una relación lazy dentro de un bucle y provocar una tormenta de consultas N+1.
- Olvidar la importación de
reflect-metadata, lo que rompe los metadatos de los decoradores en tiempo de ejecución. - Usar el repositorio
DataSourcedentro de una transacción en lugar del manager proporcionado. - Asumir que la migración generada siempre es segura; puede eliminar columnas al renombrarlas.
- Tipar una columna como
stringy dejar que TypeORM infieravarchar(255)de forma inesperada. - Almacenar dinero como float y perder precisión.
- Tratar a
findOnecomo si lanzara una excepción; devuelvenullcuando no hay coincidencias. - Mezclar los patrones Active Record y Data Mapper en la misma entidad.
Próximos pasos
TypeORM es una opción muy cómoda si trabajas con NestJS, y comprenderlo facilita la evaluación de otras alternativas. Lee la guía de Prisma para conocer un ORM basado en esquemas con un cliente generado, o la de Drizzle ORM si prefieres una capa ligera que mantenga SQL como protagonista. Para profundizar en la base de datos, la guía de PostgreSQL cubre índices, transacciones y el planificador, mientras que la de NestJS muestra cómo estructurar los servicios que envuelven tus repositorios.