O que é TypeORM?
TypeORM é um object-relational mapper para TypeScript e JavaScript. Ele permite que você descreva seu banco de dados como um conjunto de classes decoradas com metadados e, em seguida, realize consultas por meio de repositories ou de um builder fluente. Ele existe desde 2016 e é o ORM mais associado ao NestJS.
Seu diferencial é que ele suporta dois padrões conhecidos simultaneamente. No Active Record, uma entidade sabe como carregar e salvar a si mesma. No Data Mapper, as entidades são objetos simples e um repository separado detém a persistência. A maioria das equipes utiliza Data Mapper para o código da aplicação e, ocasionalmente, Active Record para modelos pequenos e autocontidos.
Se você já conhece SQL, a melhor forma de entender o TypeORM é como uma camada de mapeamento: entidades tornam-se tabelas, decorators tornam-se definições de colunas e cada consulta eventualmente se torna um SQL que você poderia ter escrito manualmente.
Entidades: classes que se tornam tabelas
Uma entidade é uma classe marcada com @Entity(). Cada instância é uma linha e cada propriedade decorada é uma coluna.
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;
}
O argumento para @Entity é o nome da tabela. Se você omiti-lo, o TypeORM derivará um a partir do nome da classe, mas ser explícito evita surpresas quando os nomes são refatorados. A opção name em @Column desacopla a coluna do banco de dados da propriedade TypeScript, que é como displayName é mapeado para display_name.
O ! após cada propriedade é a asserção de atribuição definitiva (definite assignment assertion). O TypeScript não consegue perceber que o ORM preenche esses campos em tempo de execução, então o ! diz ao compilador para confiar no banco de dados.
Decoradores de coluna que você realmente usará
O TypeORM infere o tipo da coluna a partir do tipo TypeScript da propriedade, mas a inferência é limitada. Um string poderia ser text, varchar ou char, portanto, para qualquer coisa precisa, passe o tipo explicitamente.
@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;
}
Os decoradores de colunas geradas valem a pena memorizar: @CreateDateColumn define um timestamp na inserção, @UpdateDateColumn o atualiza a cada update, e @DeleteDateColumn habilita soft deletes. @PrimaryGeneratedColumn("uuid") gera um UUID em vez de um inteiro auto-incremento, o que é útil quando os IDs são expostos publicamente.
Para dinheiro, armazene unidades menores como inteiros em uma coluna integer ou bigint e nunca use float. Para atributos genuinamente opcionais, uma coluna jsonb com { type: "jsonb" } resolve, mas qualquer coisa que você filtre ou restrinja deve estar em sua própria coluna.
O DataSource e a configuração
Desde a versão 0.3, um DataSource é o objeto único que armazena as opções de conexão e fornece repositórios, managers e transações.
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"],
});
A importação do reflect-metadata deve ocorrer antes de qualquer entidade ser carregada, pois os decorators dependem dela. Em um build compilado, aponte entities e migrations para os arquivos .js gerados em vez dos fontes .ts.
Mantenha o synchronize desativado em todos os lugares, exceto em um banco de dados local descartável. Ele reescreve o schema para corresponder às suas entidades na inicialização, o que significa que uma propriedade renomeada pode excluir silenciosamente uma coluna e seus dados. As migrations existem precisamente para que as alterações de schema sejam revisadas, versionadas e repetíveis.
Repositórios e a API find
Um repositório é um gateway tipado para uma entidade. Você obtém um através do DataSource e o utiliza para a maioria das suas leituras e escritas.
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 retorna um array e findOne retorna uma única linha ou null. O objeto de opções aceita where, order, take, skip, select, relations e withDeleted. Operadores como ILike, In, MoreThan e IsNull estão no pacote typeorm e são compostos dentro de where.
Escritas utilizam create, save e remove:
const user = users.create({ email, displayName });
await users.save(user);
user.displayName = "Ada Lovelace";
await users.save(user);
save realiza um upsert: ele insere quando a chave primária está ausente e atualiza quando ela está presente. Essa conveniência oculta se uma linha foi criada ou alterada, portanto, quando essa distinção for importante, utilize insert e update diretamente.
Relações e como elas são carregadas
As relações são declaradas em ambos os lados. O lado @ManyToOne detém a foreign key, e @OneToMany é o seu 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[];
}
Manter uma coluna authorId explícita ao lado da relação é um padrão comum e útil: você pode ler e filtrar pela foreign key sem carregar a linha relacionada.
@ManyToMany precisa de uma join table, declarada com @JoinTable no lado proprietário. @OneToOne funciona como @ManyToOne, mas impõe unicidade.
O carregamento é a parte que importa. Por padrão, as relações não são carregadas. Você deve solicitá-las com a opção relations, uma flag de eager loading ou um join via QueryBuilder.
const posts = await AppDataSource.getRepository(Post).find({
relations: { author: true },
where: { published: true },
});
Relações eager são carregadas automaticamente em cada find, e relações lazy retornam uma promise que dispara uma query ao serem acessadas. Ambas são convenientes e ambas ocultam a contagem de queries. Prefira o carregamento explícito, especialmente em caminhos críticos (hot paths), para que o custo de uma query seja visível onde ela é escrita.
O QueryBuilder para consultas reais
O QueryBuilder abrange joins, agregados e filtros dinâmicos que a API find não consegue expressar de forma limpa.
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();
Aliases são obrigatórios e os parâmetros são sempre vinculados com placeholders :name, nunca via interpolação de strings. getMany retorna entidades, getRawMany retorna linhas simples (plain rows), e getOne ou getManyAndCount cobrem os casos comuns de linha única e paginação.
Filtros dinâmicos são construídos naturalmente:
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}%` });
}
Quando uma consulta é especializada demais para o builder, dataSource.query() executa SQL parametrizado diretamente e retorna linhas brutas (raw rows).
Migrations: gerando e executando
Migrations são a única maneira segura de alterar um schema em produção. O TypeORM pode gerá-las comparando suas entities com o banco de dados ativo.
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
O arquivo gerado contém os métodos up e down. Leia-os antes de fazer o commit: o diff é uma estimativa e, ocasionalmente, pode produzir instruções destrutivas que você não pretendia.
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`);
}
}
Execute as migrations como uma etapa de deploy dedicada, utilizando uma role que possa realizar DDL, separada da role que a aplicação utiliza em tempo de execução. Nunca permita que a aplicação altere seu próprio schema durante a inicialização.
Transações
Uma transação agrupa instruções para que todas tenham sucesso ou todas sofram rollback. O helper DataSource.transaction gerencia o commit, o rollback e a conexão para você.
await AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, { email, displayName });
await manager.save(Post, { title, body, author: user });
});
Se o callback lançar um erro, o TypeORM realiza o rollback e relança a exceção. Use o manager fornecido para cada instrução dentro do bloco; um repositório obtido diretamente do DataSource utiliza uma conexão diferente e seria executado fora da transação.
Para um controle mais refinado, o createQueryRunner expõe startTransaction, commitTransaction e rollbackTransaction. Isso é útil quando a transação abrange códigos que não podem ser expressos como um único callback, mas mantenha tais transações curtas: manter uma transação aberta durante uma chamada HTTP bloqueia o vacuum e favorece a contenção de locks.
Evitando o problema N+1
O problema N+1 é o bug de performance mais comum em códigos que utilizam ORM. Você carrega uma lista de posts, percorre essa lista e acessa post.author, gerando uma query para a lista mais uma query para 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);
}
A solução é carregar a relação antecipadamente em uma única query:
const posts = await repo.find({ relations: { author: true } });
Quando você precisar apenas de uma contagem, evite carregar os filhos:
const users = await AppDataSource.getRepository(User)
.createQueryBuilder("user")
.loadRelationCountAndMap("user.postCount", "user.posts")
.getMany();
Ative o log de queries em ambiente de desenvolvimento e analise o log após carregar uma página. Se uma requisição disparar dezenas de queries quase idênticas, você tem um problema N+1, e a opção relations ou um join é a cura.
TypeORM no NestJS
@nestjs/typeorm integra o ORM com a injeção de dependência do Nest. O módulo raiz detém a conexão, e os módulos de funcionalidade (feature modules) solicitam os repositórios.
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false,
}),
TypeOrmModule.forFeature([User, Post]),
],
})
export class AppModule {}
Um serviço, então, recebe seu repositório através do construtor:
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findByEmail(email: string) {
return this.users.findOneBy({ email });
}
}
autoLoadEntities registra as entidades que foram passadas para forFeature, o que mantém a configuração raiz livre de uma longa lista de imports. Mantenha as migrations no Nest CLI ou em um script independente, em vez de executá-las na inicialização da aplicação.
Active Record ou Data Mapper?
A escolha resume-se principalmente a onde a persistência reside. O Active Record é mais conciso para modelos simples, pois a entidade carrega seus próprios métodos de consulta.
@Entity("users")
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
email!: string;
}
const user = await User.findOneBy({ id });
O Data Mapper mantém as entidades como dados puros e move toda a persistência para repositórios:
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
O Active Record é conveniente em scripts e serviços pequenos, mas acopla o modelo ao ORM e torna os testes mais difíceis. O Data Mapper é a melhor escolha padrão para código de aplicação, e é o que o NestJS incentiva. Escolha um estilo por projeto em vez de misturá-los, pois a consistência importa mais do que a escolha específica.
Melhores práticas
- Mantenha o
synchronizedesativado e gerencie cada alteração de schema com uma migration revisada. - Carregue as relações explicitamente com
relationsou um join via QueryBuilder, e ative o log de queries em desenvolvimento. - Use
@Column({ type: ... })para qualquer situação em que o tipo inferido possa estar incorreto. - Mantenha a coluna de chave estrangeira
authorIdao lado da relação para filtragens mais performáticas. - Prefira repositórios Data Mapper para o código da aplicação e reserve o Active Record para scripts.
- Use
dataSource.transactione sempre passe a transaçãomanagerpara chamadas aninhadas. - Vincule cada valor como um parâmetro; nunca interpole entradas de usuário diretamente no SQL.
- Adicione índices para as colunas que você utiliza em filtros e joins, e confirme-os com
EXPLAIN ANALYZE. - Execute as migrations com uma role de DDL dedicada, separada da role de runtime da aplicação.
Erros comuns
- Deixar
synchronize: trueem um banco de dados compartilhado ou de produção. - Acessar uma relação lazy dentro de um loop e criar uma tempestade de queries N+1.
- Esquecer o import de
reflect-metadata, o que quebra os metadados dos decorators em tempo de execução. - Usar o repositório
DataSourcedentro de uma transação em vez do manager fornecido. - Assumir que a migration gerada é sempre segura; ela pode remover colunas ao renomeá-las.
- Tipar uma coluna como
stringe deixar o TypeORM inferirvarchar(255)inesperadamente. - Armazenar dinheiro como float e perder precisão.
- Tratar
findOnecomo se ele lançasse um erro; ele retornanullquando nada corresponde. - Misturar os padrões Active Record e Data Mapper na mesma entidade.
Próximos passos
O TypeORM é uma escolha confortável se você trabalha com NestJS, e entendê-lo torna mais fácil avaliar as alternativas. Leia o guia do Prisma para um ORM schema-first com cliente gerado, ou o do Drizzle ORM se você prefere uma camada fina que mantenha o SQL em destaque. Para se aprofundar no banco de dados em si, o guia de PostgreSQL aborda índices, transações e o planner, enquanto o de NestJS mostra como estruturar os services que envolvem seus repositories.