← Back to DevBytes

TypeORM TypeScript: Strongly Typed Applications

TypeORM TypeScript: Building Strongly Typed Applications

TypeORM is a powerful Object-Relational Mapper (ORM) for Node.js that works seamlessly with TypeScript. When combined with TypeScript's static type system, TypeORM enables developers to build database-driven applications that catch errors at compile time rather than runtime. This tutorial walks through everything you need to know to build strongly typed applications with TypeORM and TypeScript.

What Is TypeORM?

TypeORM is an ORM that can run in Node.js, browser, Cordova, React Native, and Electron platforms. It supports both the Active Record and Data Mapper patterns, allowing you to choose the architecture that best fits your project. Unlike many JavaScript ORMs, TypeORM was built from the ground up with TypeScript in mind, meaning decorators, interfaces, and type inference are first-class citizens.

At its core, TypeORM translates TypeScript classes into database tables. Each property decorated with metadata becomes a column, and relationships between classes become foreign keys or join tables. Because the ORM understands TypeScript types, it can enforce that the data flowing between your application and the database conforms to the shapes you defined.

Why Strong Typing Matters

Without strong typing, database interactions become a common source of runtime errors. A misspelled column name, a wrong data type, or a missing relation can crash your application in production. TypeScript eliminates entire categories of these bugs by validating your code before it ever runs. When you use TypeORM with TypeScript, you gain several concrete benefits:

Setting Up a TypeORM TypeScript Project

Begin by initializing a new Node.js project and installing the required dependencies. You will need TypeScript, TypeORM, a database driver, and the reflect-metadata polyfill that TypeORM relies on for decorator metadata.

mkdir typed-typeorm-app
cd typed-typeorm-app
npm init -y
npm install typeorm reflect-metadata pg
npm install -D typescript ts-node @types/node
npx tsc --init

Configure your tsconfig.json to enable the features TypeORM requires. The most important settings are emitDecoratorMetadata and experimentalDecorators, both of which must be set to true.

{
  "compilerOptions": {
    "target": "ES2021",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

Defining Entities with Strong Types

Entities are TypeScript classes decorated with @Entity. Each property represents a column in the database table. By using TypeScript types alongside TypeORM decorators, you ensure that the column type and the TypeScript type stay in sync.

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

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

  @Column({ type: "varchar", length: 100, unique: true })
  email: string;

  @Column({ type: "varchar", length: 255 })
  passwordHash: string;

  @Column({ type: "varchar", length: 50 })
  firstName: string;

  @Column({ type: "varchar", length: 50 })
  lastName: string;

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

  @CreateDateColumn({ type: "timestamptz" })
  createdAt: Date;

  @UpdateDateColumn({ type: "timestamptz" })
  updatedAt: Date;
}

Notice how each property has an explicit TypeScript type that matches the database column type. The id is a string because we use UUID generation. The isActive flag is a boolean. The timestamps are Date objects. This alignment is what makes the application strongly typed end to end.

Defining Relationships Between Entities

Real-world applications have relationships between entities. TypeORM supports one-to-one, one-to-many, many-to-one, and many-to-many relationships. Let us add a Post entity that has a many-to-one relationship with User.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
} from "typeorm";
import { User } from "./User";

@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ type: "varchar", length: 200 })
  title: string;

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

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

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

  @CreateDateColumn({ type: "timestamptz" })
  createdAt: Date;
}

Now update the User entity to include the inverse side of the relationship:

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  OneToMany,
} from "typeorm";
import { Post } from "./Post";

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

  @Column({ type: "varchar", length: 100, unique: true })
  email: string;

  @Column({ type: "varchar", length: 255 })
  passwordHash: string;

  @Column({ type: "varchar", length: 50 })
  firstName: string;

  @Column({ type: "varchar", length: 50 })
  lastName: string;

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

  @CreateDateColumn({ type: "timestamptz" })
  createdAt: Date;

  @UpdateDateColumn({ type: "timestamptz" })
  updatedAt: Date;

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

The posts property on User is typed as Post[], so when you access a user's posts, TypeScript knows exactly what shape each post has. This prevents accidental access to non-existent properties.

Configuring the Database Connection

TypeORM uses a DataSource object to manage the connection to your database. Create a configuration file that initializes this data source with your entities and connection parameters.

import "reflect-metadata";
import { DataSource } from "typeorm";
import { User } from "./entities/User";
import { Post } from "./entities/Post";

export const AppDataSource = new DataSource({
  type: "postgres",
  host: process.env.DB_HOST || "localhost",
  port: parseInt(process.env.DB_PORT || "5432", 10),
  username: process.env.DB_USER || "postgres",
  password: process.env.DB_PASSWORD || "postgres",
  database: process.env.DB_NAME || "typed_app",
  synchronize: process.env.NODE_ENV !== "production",
  logging: process.env.NODE_ENV !== "production",
  entities: [User, Post],
  migrations: ["src/migrations/**/*.ts"],
  subscribers: [],
});

The synchronize option automatically creates and alters tables to match your entities. This is useful in development but should never be enabled in production because it can cause data loss. In production, use migrations instead.

Using Repositories with Type Safety

TypeORM repositories are generic, meaning they carry the entity type as a type parameter. When you retrieve the repository for User, every method on it knows it is working with User objects. This is where strong typing pays off the most.

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

async function createUser(email: string, firstName: string, lastName: string): Promise<User> {
  const userRepository = AppDataSource.getRepository(User);

  const user = userRepository.create({
    email,
    firstName,
    lastName,
    passwordHash: "hashed_value_here",
  });

  return userRepository.save(user);
}

async function findActiveUsers(): Promise<User[]> {
  const userRepository = AppDataSource.getRepository(User);

  return userRepository.find({
    where: { isActive: true },
    order: { createdAt: "DESC" },
  });
}

async function findUserByEmail(email: string): Promise<User | null> {
  const userRepository = AppDataSource.getRepository(User);
  return userRepository.findOne({ where: { email } });
}

The return types are explicit: Promise<User>, Promise<User[]>, and Promise<User | null>. If you try to pass a property that does not exist on User into the where clause, TypeScript will flag it immediately.

Writing Type-Safe Queries with QueryBuilder

The QueryBuilder is TypeORM's most powerful query tool. It allows you to build complex SQL queries programmatically. While QueryBuilder is more flexible than the repository methods, it is also slightly less type-safe by default. You can improve this by always specifying the entity alias and using typed parameters.

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

async function getPublishedPostsByAuthorEmail(email: string): Promise<Post[]> {
  return AppDataSource.getRepository(Post)
    .createQueryBuilder("post")
    .leftJoinAndSelect("post.author", "author")
    .where("author.email = :email", { email })
    .andWhere("post.published = :published", { published: true })
    .orderBy("post.createdAt", "DESC")
    .getMany();
}

async function countPostsByUser(userId: string): Promise<number> {
  return AppDataSource.getRepository(Post)
    .createQueryBuilder("post")
    .where("post.author_id = :userId", { userId })
    .getCount();
}

Using named parameters like :email and :published protects against SQL injection. The leftJoinAndSelect call eagerly loads the related author, so the returned Post objects include a fully populated author property typed as User.

Creating and Running Migrations

In production, you should manage schema changes through migrations. TypeORM can generate migrations based on entity changes, which you then review and run. First, create a migration manually to understand the structure.

import { MigrationInterface, QueryRunner, Table } from "typeorm";

export class CreateUsersTable1700000000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        name: "users",
        columns: [
          {
            name: "id",
            type: "uuid",
            isPrimary: true,
            default: "uuid_generate_v4()",
          },
          {
            name: "email",
            type: "varchar",
            length: "100",
            isUnique: true,
          },
          {
            name: "password_hash",
            type: "varchar",
            length: "255",
          },
          {
            name: "first_name",
            type: "varchar",
            length: "50",
          },
          {
            name: "last_name",
            type: "varchar",
            length: "50",
          },
          {
            name: "is_active",
            type: "boolean",
            default: true,
          },
          {
            name: "created_at",
            type: "timestamptz",
            default: "now()",
          },
          {
            name: "updated_at",
            type: "timestamptz",
            default: "now()",
          },
        ],
      }),
      true
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("users");
  }
}

Run migrations programmatically by calling queryRunner methods or by using the TypeORM CLI. The up method applies the change, and the down method reverses it, giving you full control over schema evolution.

Using the Active Record Pattern

TypeORM supports the Active Record pattern as an alternative to the Data Mapper pattern shown above. With Active Record, entities extend a base class that provides persistence methods directly on the entity itself.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  BaseEntity,
} from "typeorm";

@Entity("products")
export class Product extends BaseEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ type: "varchar", length: 200 })
  name: string;

  @Column({ type: "decimal", precision: 10, scale: 2 })
  price: number;

  @Column({ type: "int", default: 0 })
  stock: number;
}

// Usage
async function createProduct(name: string, price: number): Promise<Product> {
  const product = Product.create({ name, price, stock: 0 });
  return product.save();
}

async function findInStockProducts(): Promise<Product[]> {
  return Product.find({ where: { stock: 0 } });
}

Both patterns are valid. The Data Mapper pattern keeps entities as plain data containers and moves persistence logic to repositories, which is better for larger applications. The Active Record pattern is more concise and works well for smaller projects or simple domains.

Handling Transactions Safely

Transactions ensure that multiple database operations either all succeed or all fail together. TypeORM provides a clean transaction API using query runners. Wrapping transaction logic in a typed helper function makes it reusable and safe.

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

async function createUserWithPost(
  email: string,
  firstName: string,
  lastName: string,
  postTitle: string,
  postBody: string
): Promise<{ user: User; post: Post }> {
  const queryRunner = AppDataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const user = queryRunner.manager.create(User, {
      email,
      firstName,
      lastName,
      passwordHash: "hashed_value_here",
    });
    const savedUser = await queryRunner.manager.save(user);

    const post = queryRunner.manager.create(Post, {
      title: postTitle,
      body: postBody,
      published: false,
      author: savedUser,
    });
    const savedPost = await queryRunner.manager.save(post);

    await queryRunner.commitTransaction();
    return { user: savedUser, post: savedPost };
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}

The finally block ensures the query runner is always released, even if an error occurs. The return type Promise<{ user: User; post: Post }> guarantees that callers know exactly what they receive.

Best Practices for Strongly Typed TypeORM Applications

To get the most out of TypeORM with TypeScript, follow these best practices consistently across your codebase:

Using Enums for Type-Safe Columns

When a column can only contain a specific set of values, use a TypeScript enum. This gives you compile-time validation and makes your code more readable.

export enum PostStatus {
  DRAFT = "draft",
  PUBLISHED = "published",
  ARCHIVED = "archived",
}

@Entity("articles")
export class Article {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ type: "varchar", length: 200 })
  title: string;

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

  @Column({ type: "enum", enum: PostStatus, default: PostStatus.DRAFT })
  status: PostStatus;
}

// Usage with full type safety
async function publishArticle(id: string): Promise<void> {
  const repo = AppDataSource.getRepository(Article);
  await repo.update(id, { status: PostStatus.PUBLISHED });
}

If you accidentally pass an invalid status value, TypeScript will reject it at compile time, preventing bad data from ever reaching your database.

Custom Repositories for Complex Queries

For entities with complex query requirements, create a custom repository class. This encapsulates query logic in one place and keeps your services clean.

import { Repository, DataSource } from "typeorm";
import { User } from "./entities/User";
import { Post } from "./entities/Post";

export class UserRepository extends Repository<User> {
  constructor(dataSource: DataSource) {
    super(User, dataSource.createEntityManager());
  }

  async findUsersWithPostCount(): Promise<Array<User & { postCount: number }>> {
    return this.createQueryBuilder("user")
      .leftJoin("user.posts", "post")
      .select(["user.id", "user.email", "user.firstName", "user.lastName"])
      .addSelect("COUNT(post.id)", "postCount")
      .groupBy("user.id")
      .getRawAndEntities()
      .then(({ entities, raw }) => {
        return entities.map((entity, index) => ({
          ...entity,
          postCount: parseInt(raw[index].postCount, 10),
        }));
      });
  }

  async findActiveUsersWithPublishedPosts(): Promise<User[]> {
    return this.createQueryBuilder("user")
      .leftJoinAndSelect("user.posts", "post")
      .where("user.isActive = :isActive", { isActive: true })
      .andWhere("post.published = :published", { published: true })
      .getMany();
  }
}

Register the custom repository in your data source configuration using the customRepositories option, or instantiate it directly where needed. The typed methods on the custom repository make complex queries safe and discoverable.

Putting It All Together

Here is a complete example that initializes the data source, creates a user, creates a post for that user, and queries the data back — all with full type safety.

import "reflect-metadata";
import { AppDataSource } from "./data-source";
import { User } from "./entities/User";
import { Post } from "./entities/Post";

async function main(): Promise<void> {
  await AppDataSource.initialize();

  const userRepo = AppDataSource.getRepository(User);
  const postRepo = AppDataSource.getRepository(Post);

  const user = userRepo.create({
    email: "jane@example.com",
    firstName: "Jane",
    lastName: "Doe",
    passwordHash: "hashed_value_here",
  });
  const savedUser = await userRepo.save(user);

  const post = postRepo.create({
    title: "Getting Started with TypeORM",
    body: "TypeORM makes database access type-safe and enjoyable.",
    published: true,
    author: savedUser,
  });
  await postRepo.save(post);

  const usersWithPosts = await userRepo.find({
    relations: ["posts"],
    where: { isActive: true },
  });

  usersWithPosts.forEach((u) => {
    console.log(`${u.firstName} ${u.lastName} has ${u.posts.length} post(s)`);
    u.posts.forEach((p) => {
      console.log(`  - ${p.title} (published: ${p.published})`);
    });
  });

  await AppDataSource.destroy();
}

main().catch((error) => {
  console.error("Application error:", error);
  process.exit(1);
});

Every variable in this example has a known type. The usersWithPosts array is typed as User[], each user's posts property is typed as Post[], and each post's properties are known to the compiler. If you mistype a property name or pass the wrong type of argument, the TypeScript compiler catches it before the code runs.

TypeORM combined with TypeScript provides a robust foundation for building database-driven applications that are maintainable, safe, and self-documenting. By defining strongly typed entities, leveraging typed repositories, using migrations for schema management, and following consistent best practices, you can eliminate entire classes of runtime errors and build applications that scale confidently. The upfront investment in type safety pays dividends throughout the lifecycle of your project, from initial development through refactoring and long-term maintenance.

— Ad —

Google AdSense will appear here after approval

← Back to all articles