TypeORM Complete Guide | TypeScript ORM for Node.js

TypeORM Complete Guide | TypeScript ORM for Node.js

이 글의 핵심

TypeORM is a TypeScript-first ORM for Node.js that works with PostgreSQL, MySQL, SQLite, and more. It provides decorators, migrations, relations, and full type safety.

Introduction

TypeORM is an ORM (Object-Relational Mapping) library that brings SQL databases to TypeScript and Node.js with decorators, repositories, migrations, and full type safety.

Why TypeORM?

Raw SQL:

const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
const user = result.rows[0]; // No types

With TypeORM:

@Entity()
class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;
}

const user = await userRepository.findOne({ where: { email } }); // Fully typed!

1. Installation

npm install typeorm reflect-metadata
npm install -D @types/node

# Database driver (choose one)
npm install pg          # PostgreSQL
npm install mysql2      # MySQL
npm install sqlite3     # SQLite

2. Configuration

// src/data-source.ts
import { DataSource } from 'typeorm';
import { User } from './entities/User';

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'postgres',
  password: 'password',
  database: 'myapp',
  entities: [User],
  synchronize: true, // Auto-create tables (dev only!)
  logging: true,
});

// Initialize
await AppDataSource.initialize();

3. Entity Definition

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('users') // Table name
export class User {
  @PrimaryGeneratedColumn()
  id: number;

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

  @Column()
  name: string;

  @Column({ nullable: true })
  bio: string | null;

  @Column({ type: 'int', default: 0 })
  age: number;

  @Column({ type: 'varchar', length: 50 })
  username: string;

  @Column({ default: true })
  isActive: boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

4. Repository Pattern

import { AppDataSource } from './data-source';
import { User } from './entities/User';

const userRepository = AppDataSource.getRepository(User);

// Create
const user = userRepository.create({
  email: '[email protected]',
  name: 'Alice',
});
await userRepository.save(user);

// Find all
const users = await userRepository.find();

// Find one
const user = await userRepository.findOne({
  where: { email: '[email protected]' }
});

// Find with conditions
const activeUsers = await userRepository.find({
  where: { isActive: true }
});

// Update
await userRepository.update(
  { id: 1 },
  { name: 'Alice Updated' }
);

// Delete
await userRepository.delete({ id: 1 });

// Count
const count = await userRepository.count();

5. Query Builder

// Complex query
const users = await userRepository
  .createQueryBuilder('user')
  .where('user.age > :age', { age: 18 })
  .andWhere('user.isActive = :isActive', { isActive: true })
  .orderBy('user.createdAt', 'DESC')
  .limit(10)
  .getMany();

// With relations
const users = await userRepository
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'post')
  .where('post.published = :published', { published: true })
  .getMany();

// Aggregate
const result = await userRepository
  .createQueryBuilder('user')
  .select('COUNT(*)', 'count')
  .where('user.isActive = :isActive', { isActive: true })
  .getRawOne();

6. Relations

One-to-Many

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}

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

  @Column()
  title: string;

  @ManyToOne(() => User, (user) => user.posts)
  author: User;
}

// Usage
const user = await userRepository.findOne({
  where: { id: 1 },
  relations: ['posts'], // Load posts
});

console.log(user.posts); // Post[]

Many-to-Many

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

  @Column()
  title: string;

  @ManyToMany(() => Tag, (tag) => tag.posts)
  @JoinTable() // Creates join table
  tags: Tag[];
}

@Entity()
export class Tag {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

// Usage
const post = await postRepository.findOne({
  where: { id: 1 },
  relations: ['tags'],
});

console.log(post.tags); // Tag[]

One-to-One

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToOne(() => Profile, (profile) => profile.user)
  @JoinColumn()
  profile: Profile;
}

@Entity()
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  bio: string;

  @OneToOne(() => User, (user) => user.profile)
  user: User;
}

7. Migrations

Generate Migration

# Auto-generate from entity changes
npx typeorm migration:generate -d src/data-source.ts -n CreateUserTable

# Create empty migration
npx typeorm migration:create src/migrations/CreateUserTable

Migration File

// src/migrations/1234567890-CreateUserTable.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUserTable1234567890 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE "users" (
        "id" SERIAL PRIMARY KEY,
        "email" VARCHAR UNIQUE NOT NULL,
        "name" VARCHAR NOT NULL,
        "createdAt" TIMESTAMP DEFAULT NOW()
      )
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE "users"`);
  }
}

Run Migrations

# Run migrations
npx typeorm migration:run -d src/data-source.ts

# Revert last migration
npx typeorm migration:revert -d src/data-source.ts

# Show migration status
npx typeorm migration:show -d src/data-source.ts

8. Transactions

await AppDataSource.transaction(async (manager) => {
  // Create user
  const user = manager.create(User, {
    email: '[email protected]',
    name: 'Alice',
  });
  await manager.save(user);

  // Create post
  const post = manager.create(Post, {
    title: 'My First Post',
    author: user,
  });
  await manager.save(post);

  // Both saved or both rolled back
});

9. Validation

npm install class-validator
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { IsEmail, MinLength, MaxLength, Min, Max } from 'class-validator';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @IsEmail()
  email: string;

  @Column()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @Column()
  @Min(0)
  @Max(120)
  age: number;
}

// Validate before save
import { validate } from 'class-validator';

const user = new User();
user.email = 'invalid-email';

const errors = await validate(user);
if (errors.length > 0) {
  console.log('Validation failed:', errors);
}

10. Custom Repository

import { EntityRepository, Repository } from 'typeorm';

@EntityRepository(User)
export class UserRepository extends Repository<User> {
  async findByEmail(email: string): Promise<User | null> {
    return this.findOne({ where: { email } });
  }

  async findActiveUsers(): Promise<User[]> {
    return this.find({ where: { isActive: true } });
  }

  async findWithPosts(userId: number): Promise<User | null> {
    return this.findOne({
      where: { id: userId },
      relations: ['posts'],
    });
  }
}

// Usage
const userRepository = AppDataSource.getCustomRepository(UserRepository);
const user = await userRepository.findByEmail('[email protected]');

11. Subscribers (Hooks)

import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
import { User } from './entities/User';

@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
  listenTo() {
    return User;
  }

  beforeInsert(event: InsertEvent<User>) {
    console.log('Before user insert:', event.entity);
  }

  afterInsert(event: InsertEvent<User>) {
    console.log('After user insert:', event.entity);
  }

  beforeUpdate(event: UpdateEvent<User>) {
    console.log('Before user update:', event.entity);
  }

  afterUpdate(event: UpdateEvent<User>) {
    console.log('After user update:', event.entity);
  }
}

12. Real-World Example: Blog API

// entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { Post } from './Post';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

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

  @Column()
  username: string;

  @Column()
  password: string;

  @Column({ default: 'user' })
  role: 'user' | 'admin';

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];

  @CreateDateColumn()
  createdAt: Date;
}

// entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, ManyToMany, JoinTable, CreateDateColumn } from 'typeorm';
import { User } from './User';
import { Tag } from './Tag';

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

  @Column()
  title: string;

  @Column({ unique: true })
  slug: string;

  @Column('text')
  content: string;

  @Column({ default: false })
  published: boolean;

  @ManyToOne(() => User, (user) => user.posts)
  author: User;

  @ManyToMany(() => Tag, (tag) => tag.posts)
  @JoinTable()
  tags: Tag[];

  @CreateDateColumn()
  createdAt: Date;
}

// entities/Tag.ts
@Entity('tags')
export class Tag {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  name: string;

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

// Express API
import express from 'express';
import { AppDataSource } from './data-source';
import { Post } from './entities/Post';
import { User } from './entities/User';

const app = express();
app.use(express.json());

const postRepository = AppDataSource.getRepository(Post);
const userRepository = AppDataSource.getRepository(User);

// Get all posts
app.get('/api/posts', async (req, res) => {
  try {
    const posts = await postRepository.find({
      where: { published: true },
      relations: ['author', 'tags'],
      order: { createdAt: 'DESC' },
    });

    res.json({ posts });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Get single post
app.get('/api/posts/:slug', async (req, res) => {
  try {
    const post = await postRepository.findOne({
      where: { slug: req.params.slug },
      relations: ['author', 'tags'],
    });

    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }

    res.json({ post });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Create post
app.post('/api/posts', authenticate, async (req, res) => {
  try {
    const { title, content, tagNames } = req.body;

    const author = await userRepository.findOne({ where: { id: req.user.id } });

    const post = postRepository.create({
      title,
      slug: title.toLowerCase().replace(/\s+/g, '-'),
      content,
      author,
    });

    await postRepository.save(post);

    res.status(201).json({ post });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Update post
app.put('/api/posts/:id', authenticate, async (req, res) => {
  try {
    const post = await postRepository.findOne({
      where: { id: parseInt(req.params.id) },
      relations: ['author'],
    });

    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }

    if (post.author.id !== req.user.id) {
      return res.status(403).json({ error: 'Unauthorized' });
    }

    Object.assign(post, req.body);
    await postRepository.save(post);

    res.json({ post });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

await AppDataSource.initialize();
app.listen(3000);

13. Soft Deletes

import { Entity, Column, DeleteDateColumn } from 'typeorm';

@Entity()
export class User {
  @Column()
  name: string;

  @DeleteDateColumn()
  deletedAt?: Date; // Soft delete timestamp
}

// Soft delete
await userRepository.softDelete({ id: 1 });

// Find with deleted
const users = await userRepository.find({ withDeleted: true });

// Restore
await userRepository.restore({ id: 1 });

// Hard delete
await userRepository.delete({ id: 1 });

14. Indexing

import { Entity, Column, Index } from 'typeorm';

@Entity()
@Index(['email', 'username']) // Compound index
export class User {
  @Column()
  @Index() // Single column index
  email: string;

  @Column()
  @Index({ unique: true }) // Unique index
  username: string;

  @Column()
  @Index('idx_name') // Named index
  name: string;
}

15. Best Practices

1. Use Repositories

// Good: use repository
const userRepository = AppDataSource.getRepository(User);
const users = await userRepository.find();

// Bad: direct query
await AppDataSource.query('SELECT * FROM users');

2. Disable synchronize in Production

export const AppDataSource = new DataSource({
  synchronize: process.env.NODE_ENV === 'development', // Only in dev
  migrations: ['src/migrations/*.ts'],
});

3. Use Migrations

# Generate migration from entity changes
npx typeorm migration:generate -d src/data-source.ts -n AddUserRole

# Run in production
npx typeorm migration:run -d dist/data-source.js

Summary

TypeORM brings type-safe SQL to Node.js:

  • Decorators for entity definition
  • Repository pattern for clean queries
  • Migrations for schema management
  • Relations (1-1, 1-N, N-N)
  • TypeScript-first with full inference

Key Takeaways:

  1. Use entities with decorators
  2. Leverage repositories
  3. Migrations for production
  4. Relations for complex data
  5. Query builder for flexibility

Next Steps:

  • Compare Prisma
  • Learn PostgreSQL
  • Build Express API

Resources: