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:
- Compile-time validation of queries and entity references
- Autocompletion for column names, relations, and repository methods
- Refactoring safety — renaming a column updates every query that references it
- Self-documenting code through explicit interfaces and types
- Reduced need for manual runtime validation of database results
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:
- Always declare explicit TypeScript types on entity properties rather than relying on type inference from decorators alone.
- Use
strict: truein yourtsconfig.jsonto catch null and undefined issues early. - Prefer the Data Mapper pattern for applications with complex business logic, and reserve Active Record for simpler domains.
- Never enable
synchronizein production. Use migrations for all schema changes. - Use named parameters in QueryBuilder to prevent SQL injection and improve readability.
- Define return types explicitly on all repository and service methods so consumers of your code benefit from type safety.
- Create custom repository classes for complex query logic, extending TypeORM's base repository with typed methods.
- Validate input data at the application boundary using a validation library like
class-validatoralongside TypeORM entities. - Keep entities focused on persistence concerns and move business logic into service classes to maintain a clean separation of concerns.
- Use enums for columns with a fixed set of values, and define them as TypeScript enums so both the database and application stay in sync.
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.