Skip to content

TypeORM (TypeScript / JavaScript)

A decorator-based, code-first ORM for TypeScript and JavaScript whose distinguishing trait is that one entity model — a class annotated with @Entity / @Column / relation decorators — drives two persistence styles: the Active Record pattern (class User extends BaseEntityUser.findOneBy(...), user.save()) and the Data Mapper pattern (dataSource.getRepository(User)repo.save(user)).

FieldValue
LanguageTypeScript / JavaScript (ES2023+), Node.js + browser/Cordova/Ionic/React Native/Expo/Electron
LicenseMIT (LICENSE; package.json "license")
Repositorytypeorm/typeorm
Documentationtypeorm.io · docs/ in-repo
CategoryFull ORM supporting both Active Record and Data Mapper patterns — decorator-based, code-first
Abstraction levelFull-ORM rung: entities with declared relations, per-save change diffing, cascades, migrations
Query modelDecorators + repository find-options · fluent QueryBuilder · raw SQL / tagged template — a runtime value rendered to dialect SQL at execution
Effect/async modelAsync (Promise / async); failures are thrown (QueryFailedError / TypeORMError), not a typed error channel
BackendsPostgreSQL, MySQL/MariaDB, CockroachDB, SQLite (better-sqlite3 / sql.js), MS SQL Server, Oracle, SAP HANA, Google Spanner, MongoDB (DataSourceOptions)
First release0.0.1 ≈ 2016 (web-attested; CHANGELOG.md: "first stable version, works with TypeScript 1.x")
Latest version1.0.0 @ 2026-05-19 (the pinned tree; CHANGELOG.md top entry — the release that finished the ConnectionDataSource rename)

NOTE

TypeORM is this survey's data point for a decorator-driven full ORM that lets one entity model be used two ways. Where Prisma puts the schema in a separate .prisma DSL and generates a client, and Drizzle / Kysely expose typed builders with no decorators, TypeORM's model is ordinary TypeScript classes tagged with metadata decorators (reflect-metadata reads the emitted types). It sits at the full-ORM rung alongside Hibernate, SQLAlchemy, and Rails ActiveRecord — with change tracking, cascades, lazy relations, and a migration runner — but stacks the Active Record and Data Mapper surfaces on the same metadata. Terms below link to concepts.


Overview

What it solves

TypeORM maps SQL tables to typed TypeScript classes and lets you query and mutate them through a repository/entity-manager API (Data Mapper) or through methods on the entity itself (Active Record). Its scope is deliberately maximal — the README positions it as a run-anywhere, do-everything ORM (README.md):

"TypeORM is an ORM that can run in Node.js, Browser, Cordova, Ionic, React Native, NativeScript, Expo, and Electron platforms and can be used with TypeScript and JavaScript (ES2023). Its goal is to always support the latest JavaScript features and provide additional features that help you to develop any kind of application that uses databases - from small applications with a few tables to large-scale enterprise applications with multiple databases."

The package.json description names the lineage and the breadth of backends (package.json):

"Data-Mapper ORM for TypeScript and ES2023+. Supports MySQL/MariaDB, PostgreSQL, MS SQL Server, Oracle, SAP HANA, SQLite, MongoDB databases."

The README names its influences directly — "TypeORM is highly influenced by other ORMs, such as Hibernate, Doctrine and Entity Framework" (README.md) — so the mental model is Hibernate/JPA, not a thin query builder.

Design philosophy

The single claim TypeORM leads with — the one the rest of the library is organized around — is that it offers two enterprise patterns off one model (README.md):

"TypeORM supports both Active Record and Data Mapper patterns, unlike all other JavaScript ORMs currently in existence, which means you can write high-quality, loosely coupled, scalable, maintainable applications in the most productive way."

Both are grounded in the same decorated class. The README shows the Data Mapper form — a plain entity plus a repository (README.md):

typescript
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

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

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column()
  age: number;
}

const userRepository = MyDataSource.getRepository(User);
await userRepository.save(user);
const allUsers = await userRepository.find();
const firstUser = await userRepository.findOneBy({ id: 1 });

and the Active Record form — the same shape, but extends BaseEntity, so persistence methods live on the class (README.md):

typescript
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm';

@Entity()
export class User extends BaseEntity {
  /* @PrimaryGeneratedColumn / @Column … */
}

await user.save();
const allUsers = await User.find();
const timber = await User.findOneBy({ firstName: 'Timber', lastName: 'Saw' });

The guide frames the choice as a maintainability-vs-simplicity trade, not a technical one (docs/docs/guides/1-active-record-data-mapper.md): "The Data Mapper approach helps with maintainability, which is more effective in larger apps. The Active Record approach helps keep things simple which works well in smaller apps." BaseEntity is literally "Base abstract entity for all entities, used in ActiveRecord patterns" (src/repository/BaseEntity.ts), and every static/instance method on it just delegates to a repository — save(options?) calls baseEntity.getRepository().save(this, options) — so Active Record is a thin veneer over the Data Mapper machinery, not a parallel implementation. This is the whole design in one fact: the two "patterns" share the persistence engine (§ Effect model), a Repository that is "supposed to work with your entity objects" (src/repository/Repository.ts).

The metadata approach is decorator-driven and code-first: decorators push into a global getMetadataArgsStorage() at class-definition time, and TypeORM builds an EntityMetadata graph from that. @Entity marks "classes that will be an entity (table or document depend on database type)" (src/decorator/entity/Entity.ts) and @Column marks "a specific class property as a table column", with the load-bearing caveat that "Only properties decorated with this decorator will be persisted to the database when entity be saved" (src/decorator/columns/Column.ts).


Connection, pooling & resource lifetime

A DataSource is the connection configuration and the root object. Its docstring records the rename that the pinned 1.0.0 finalized (src/data-source/DataSource.ts):

"DataSource is a pre-defined connection configuration to a specific database. You can have multiple data sources connected (with multiple connections in it), connected to multiple databases in your application. Before, it was called Connection, but now Connection is deprecated because Connection isn't the best name for what it's actually is."

You configure it with a DataSourceOptions (a discriminated union over the driver typePostgresDataSourceOptions, MysqlDataSourceOptions, SqliteDataSourceOptions, … — src/data-source/DataSourceOptions.ts) and open it with await dataSource.initialize(), which builds metadata, connects the driver, optionally runs migrations and/or synchronize, then sets isInitialized = true (src/data-source/DataSource.ts). Pooling is the driver's job, not TypeORM's: the README lists "Connection pooling" and "Replication" among features, and each relational driver wraps its native pool (pg, mysql2, mssql, …, declared as optional peerDependencies in package.json). A QueryRunner is the object that holds a single leased connection for the duration of a unit of work — every transaction, migration, and schema-builder step runs on one; dataSource.query(...) without an explicit runner leases one and releases it in a finally (src/data-source/DataSource.ts). Resource lifetime is therefore callback-scoped (transaction(cb)) or manually managed (queryRunner.release()), not a type-level scoped acquire/release — a leaked QueryRunner is a runtime pool leak, not a compile error. For large result sets, the README advertises "Streaming raw results" over a server-side cursor.

Query construction & injection safety

TypeORM offers three query surfaces over the same entity metadata, in ascending order of control and descending order of abstraction.

1. Repository find-options — the declarative surface. repository.find(options) / findOne(options) / findOneBy(where) take a plain object describing the query. The shape is typed against the entity (src/find-options/FindOneOptions.ts): where is a "Simple condition that should be applied to match entities", relations"Indicates what relations of entity should be loaded (simplified left join form)", plus select, order, take/skip, lock, and cache. Values in a where object are never string-concatenated — they flow into the QueryBuilder below and become bound parameters. The README's canonical example (README.md):

typescript
const timber = await userRepository.findOneBy({
  firstName: 'Timber',
  lastName: 'Saw',
}); // find by firstName and lastName

FindOptionsWhere supports operators (In, LessThan, Like, IsNull, Between, …) that wrap a value as a FindOperator, so even Like("%chocolate%") binds rather than interpolates.

2. The fluent QueryBuilder — the SQL-shaped surface. For joins, sub-queries, and clauses the find-options can't express, createQueryBuilder(alias) returns a SelectQueryBuilder (siblings: Insert/Update/Delete/SoftDelete/Relation) whose methods mirror SQL. It carries named :param placeholders. The active-record guide's findByName shows the idiom verbatim (docs/docs/guides/1-active-record-data-mapper.md):

typescript
static findByName(firstName: string, lastName: string) {
    return this.createQueryBuilder("user")
        .where("user.firstName = :firstName", { firstName })
        .andWhere("user.lastName = :lastName", { lastName })
        .getMany()
}

where(expr, parameters?) "Sets WHERE condition in the query builder … Additionally you can add parameters used in where expression" (src/query-builder/SelectQueryBuilder.ts). The { firstName } object never enters the SQL text; it is stored in expressionMap.parameters by setParameter, which validates the key against a character allow-list to keep an attacker from smuggling structure through a parameter name (src/query-builder/QueryBuilder.ts):

typescript
if (!key.match(/^([A-Za-z0-9_.]+)$/)) {
  throw new TypeORMError(
    'QueryBuilder parameter keys may only contain numbers, letters, underscores, or periods.',
  );
}

Values that TypeORM itself injects (a find-option where value, a comparison operand) are captured by createParameter, which mints a fresh orm_param_N name, stores the value, and emits only the placeholder :orm_param_N into the SQL (src/query-builder/QueryBuilder.ts).

Placeholders become bound parameters at execution. getQueryAndParameters() renders the SQL and then calls driver.escapeQueryWithParameters(query, parameters) (src/query-builder/QueryBuilder.ts). Each driver rewrites the named :name placeholders into its native positional form and pulls the values out into an ordered array on a separate channel — the mechanism that makes SQL injection structurally impossible. Postgres, for example, replaces :name with $1, $2, … and appends the values to escapedParameters (src/driver/postgres/PostgresDriver.ts):

typescript
sql = sql.replaceAll(/:(\.\.\.)?([A-Za-z0-9_.]+)/g, (full, isArray, key) => {
  // …
  escapedParameters.push(value);
  return this.createParameter(key, escapedParameters.length - 1); // -> "$N"
});

The :(\.\.\.)? form (:...ids) expands an array into a placeholder list, so WHERE id IN (:...ids) binds each element rather than string-joining them.

3. Raw SQL — the escape hatch, still parameterized. dataSource.query(sql, params)"Executes raw SQL query and returns raw database results" with a positional/named parameters array (src/data-source/DataSource.ts). For interpolation that stays safe, 1.0.0 adds an sql tagged template whose expressions are auto-bound (src/data-source/DataSource.ts):

"Tagged template function that executes raw SQL query and returns raw database results. Template expressions are automatically transformed into database parameters."

typescript
dataSource.sql`SELECT * FROM table_name WHERE id = ${id}`;

buildSqlTag walks the template: each ${expr} becomes a driver placeholder via driver.createParameter(...) and the value is pushed onto a parameters array, never the SQL string (src/util/SqlTagUtils.ts). The genuinely unsafe door is building a SQL string yourself and passing it to query() with the value already concatenated in — which no API forces you toward.

Schema, migrations & code generation

TypeORM is code-first: the decorated entities are the schema (schema stances). Two paths turn them into DDL.

Auto-synchronization (development). dataSource.synchronize() (or the synchronize: true option, run during initialize) diffs the entity metadata against the live database and issues the CREATE/ALTER needed to converge, via the schema-builder. The docs and the README are emphatic that this is a dev-only convenience — it can drop columns/data — but it means "Schema declaration in models or separate configuration files" needs no migration for prototyping.

Migrations (production). A migration is a class implementing MigrationInterface with up(queryRunner) / down(queryRunner); MigrationExecutor "Executes migrations: runs pending and reverts previously executed migrations" (src/migration/MigrationExecutor.ts), recording applied ones in a bookkeeping table and wrapping them per a transaction mode (src/migration/MigrationExecutor.ts):

"Indicates how migrations should be run in transactions. all: all migrations are run in a single transaction / none: all migrations are run without a transaction / each: each migration is run in a separate transaction"

The headline feature is generation from the diff: the CLI's migration:generate <path> command asks the schema-builder for the SqlInMemory of upQueries / downQueries needed to bring the DB in line with the entities, and writes them into a migration file — "No changes in database schema were found - cannot generate a migration" when the diff is empty (src/commands/MigrationGenerateCommand.ts). So the workflow is: edit the decorated entities, migration:generate to author the DDL, review, commit. (You can also migration:create an empty migration and write DDL by hand.) This is the same code-first stance as Prisma and EF Core, but sourced from decorated classes rather than a .prisma file or C# model. There is no first-party db-first codegen in-tree; the community typeorm-model-generator fills that gap (README.md).

Type mapping & result decoding

Column types are declared on the decorator (@Column("varchar"), @Column({ type: "jsonb" })), spanning per-database types the driver maps to native encoders/decoders. Nullability is expressed as @Column({ nullable: true }) and reflected in the TypeScript field type (name: string | null). Row hydration is metadata-driven: find/QueryBuilder.getMany() construct entity instances and populate only mapped columns, with relations attached according to the load strategy below. @Column's "only decorated properties persist" rule is the decode-side contract too — an un-decorated field is neither written nor read. Special columns get dedicated decorators (@PrimaryGeneratedColumn for auto-increment/uuid keys, @CreateDateColumn, @UpdateDateColumn, @DeleteDateColumn for soft-delete, @VersionColumn for optimistic locking), and @Column(() => Profile) embeds a value object. Because the mapping leans on reflect-metadata's design:type, the TypeScript compiler must emit decorator metadata (experimentalDecorators + emitDecoratorMetadata), and reflect-metadata must be imported once at startup (docs/docs/getting-started.md) — a hard dependency on that reflection shim is a defining constraint (§ Weaknesses).

Effect model, transactions & error handling

This is the dimension the survey weights most heavily, and the one where TypeORM's mainstream-ORM heritage shows most.

Async Promise, not an effect value. Every terminal operation is async: repository.find(), user.save(), qb.getMany(), dataSource.query() all return Promises awaited by the caller. There is no IO / Effect / ConnectionIO wrapper (effect-typed APIs in the survey's sense) and no type-level error channel — the "effect" is a plain JS promise, and failures are thrown, not returned.

Persistence is whole-graph, diffed against loaded state. save(entity) does far more than a single INSERT/UPDATE; it drives the persistence/ subject executor, which is where TypeORM's Unit of Work-style machinery lives. The pipeline: a Subject "is a subject of persistence … holds information about each entity that needs to be persisted" (src/persistence/Subject.ts); the CascadesSubjectBuilder "Finds all cascade operations of the given subject and cascade operations of the found cascaded subjects, e.g. builds a cascade tree" (src/persistence/subject-builder/CascadesSubjectBuilder.ts), so a save of a User with cascade-flagged relations also persists its Profile and Posts; the SubjectDatabaseEntityLoader then loads each entity's current DB row (src/persistence/SubjectDatabaseEntityLoader.ts):

"Loads database entities for all operate subjects which do not have database entity set. All entities that we load database entities for are marked as updated or inserted. To understand which of them really needs to be inserted or updated we need to load their original representations from the database."

SubjectChangedColumnsComputer then "Finds what columns are changed in the subject entities" — its internal step is documented as "Differentiate columns from the updated entity and entity stored in the database" (src/persistence/SubjectChangedColumnsComputer.ts) — a snapshot diff against the freshly-loaded row, per save call, so an UPDATE touches only changed columns. Finally SubjectExecutor "Executes all database operations (inserts, updated, deletes) that must be executed with given persistence subjects" (src/persistence/SubjectExecutor.ts), ordering them with a SubjectTopologicalSorter that "Orders insert or remove subjects in proper order (using topological sorting)" so FK dependencies are satisfied (src/persistence/SubjectTopologicalSorter.ts). This is change tracking realized as load-then-diff at save time rather than a long-lived session snapshot: TypeORM has no per-session identity map that would return the same instance for two loads, and no ambient flush — the "unit of work" is the graph reachable from one save() call. BaseEntity.save documents the upsert semantics directly: "Saves current entity in the database. If entity does not exist in the database then inserts, otherwise updates" (src/repository/BaseEntity.ts).

Transactions: callback or manual, nested via SAVEPOINT. dataSource.transaction(cb)"Wraps given function execution (and all operations made there) into a transaction. All database operations must be executed using provided entity manager" (src/data-source/DataSource.ts). The EntityManager implementation opens a QueryRunner, startTransaction, runs the callback, commits on success and rolls back on a thrown error (src/entity-manager/EntityManager.ts):

typescript
await queryRunner.startTransaction(isolation);
const result = await runInTransaction(queryRunner.manager);
await queryRunner.commitTransaction();
// catch: await queryRunner.rollbackTransaction(); throw err

An optional first argument sets the isolation level. Nesting is real and implemented with savepoints: the query runner tracks a transactionDepth, so an outer transaction emits START TRANSACTION while an inner one emits SAVEPOINT typeorm_N, with commit/rollback mapping to RELEASE SAVEPOINT / ROLLBACK TO SAVEPOINT (src/driver/postgres/PostgresQueryRunner.ts):

typescript
if (this.transactionDepth === 0) {
  await this.query('START TRANSACTION');
  // SET TRANSACTION ISOLATION LEVEL …
} else {
  await this.query(`SAVEPOINT typeorm_${this.transactionDepth}`);
}
this.transactionDepth += 1;

so an inner rollback discards only the inner work while the outer transaction survives.

Errors are thrown exceptions, not a typed channel. A failed query throws a QueryFailedError"Thrown when query execution has failed" — which carries the query, the parameters, and the raw driverError (src/error/QueryFailedError.ts); a missing row from findOneOrFail throws EntityNotFoundError; optimistic-lock conflicts throw OptimisticLockVersionMismatchError; everything derives from TypeORMError (src/error/TypeORMError.ts). There is no Result/Either and no isRetryable-style classification — to distinguish a unique-key violation from a deadlock you inspect error.driverError (the underlying pg/mysql2 error and its SQLSTATE code) yourself. This is the ordinary exception posture of the mainstream ORMs (Hibernate, EF Core), the opposite end of the axis from the functional data mappers this survey weights (doobie, Effect TS), which keep the failure set in the effect's type.

Relations & the N+1 hazard. Relations are declared with @ManyToOne / @OneToMany / @OneToOne / @ManyToMany plus @JoinColumn / @JoinTable. @ManyToOne documents the ownership rule — "Entity1 is the owner of the relationship, and stores the id of Entity2 on its side of the relation" (src/decorator/relations/ManyToOne.ts). Loading has three modes, and the choice is where N+1 lurks:

  • Eager via joinfind({ relations: { posts: true } }) (the "simplified left join form" of FindOneOptions) or qb.leftJoinAndSelect("post.category", "category") pulls the relation in one query. FindOneOptions even exposes relationLoadStrategy to pick "join" (one query with joins) vs "query" (a separate WHERE … IN per relation) — "If you are loading too much data with nested joins it's better to load relations using separate queries" (src/find-options/FindOneOptions.ts).
  • Lazy Promise-typed relations — if a relation property is typed Promise<Post[]>, TypeORM auto-marks it lazy (OneToMany inspects the reflected design:type and sets isLazy when the type name is promisesrc/decorator/relations/OneToMany.ts); the RelationLoader then "provides lazy-load wrappers via getters/setters" (src/query-builder/RelationLoader.ts), so touching await user.posts fires a query. Convenient, and the classic N+1 foot-gun: iterating N users and awaiting each user.posts is N+1 round-trips.
  • RelationId / relation counts — load only the foreign keys or counts without the full rows.

Ecosystem & maturity

TypeORM is one of the most-depended-upon ORMs in the Node ecosystem — the default ORM in much of the NestJS world — released under the permissive MIT license (LICENSE). It supports "more databases than any other JS/TS ORM" (README.md): PostgreSQL, MySQL/MariaDB, CockroachDB, SQLite (via sqlite3, better-sqlite3, sql.js, Capacitor, Cordova, Expo, React Native, NativeScript), MS SQL Server, Oracle, SAP HANA, Google Spanner, and MongoDB (as a document store) — the DataSourceOptions union enumerates all of them (src/data-source/DataSourceOptions.ts), and the database packages are optional peerDependencies (package.json). The feature surface is enormous: the README's feature list runs to ~40 bullets — eager/lazy relations, cascades, closure-table trees, multiple inheritance patterns, replication, cross-database queries, query caching, listeners/subscribers (hooks), a CLI, ESM + CommonJS. A community-extension ecosystem surrounds it (typeorm-model-generator, typeorm-extension, fixtures loaders, ER-diagram generators — README.md). First released as 0.0.1 around 2016 ("first stable version, works with TypeScript 1.x", CHANGELOG.md); the project spent years on the 0.3.x line and the pinned tree is 1.0.0 (2026-05-19) — the release that dropped Node 16/18 and finished renaming Connection to DataSource (CHANGELOG.md).

Strengths

Weaknesses

  • Runtime, not compile-time, query checking. A where referencing a non-existent column, or a QueryBuilder string typo, is a runtime EntityPropertyNotFoundError / QueryFailedError, not a type error — unlike Kysely/Drizzle's typed builders or Prisma's generated client.
  • reflect-metadata + decorator config dependency. Requires the reflect-metadata shim imported at startup and experimentalDecorators / emitDecoratorMetadata in tsconfig (docs/docs/getting-started.md) — friction with bundlers, SWC/esbuild, and the newer TC39 decorators.
  • No typed error channel. Failures are thrown TypeORMError subclasses; distinguishing a unique violation from a deadlock means reaching into error.driverError (src/error/QueryFailedError.ts) — no encoded, retryable-aware error set.
  • Lazy Promise relations are an N+1 trap. Auto-lazy on Promise-typed properties (src/decorator/relations/OneToMany.ts) makes per-parent queries easy to trigger accidentally.
  • Large, sometimes rough, feature surface. ~40 headline features and many overlapping APIs (find-options vs QueryBuilder vs raw) mean more to learn and more edge cases; the QueryBuilder source still carries a long // todo: list (src/query-builder/QueryBuilder.ts).
  • No first-party db-first codegen. Scaffolding entities from a live schema needs a community tool (README.md).
  • save semantics can surprise. The load-diff-cascade pipeline issues extra SELECTs and can cascade further than intended if cascade flags are broad (src/persistence/subject-builder/CascadesSubjectBuilder.ts).

Key design decisions and trade-offs

DecisionRationaleTrade-off
Decorator-based, code-first entities via reflect-metadataModel is plain TS classes; schema, columns, relations declared inline; no separate DSLHard dependency on reflect-metadata + experimentalDecorators; no compile-time query verification
Both Active Record and Data Mapper from one entityFamiliar to Hibernate/Rails users; pick per-app-size; AR is a BaseEntity veneerTwo surfaces to document/learn; AR couples domain objects to persistence
Three query surfaces (find-options · QueryBuilder · raw/sql)Declarative for the common case, fluent for joins, raw for the rest — all parameterizedOverlapping APIs; when to reach for which is a judgment call; find-options can't express everything
:param placeholders → escapeQueryWithParameters per driverInjection impossible for values; SQL and data on separate channels; multi-dialectYou must pass values as params, never concatenate; the one unsafe door is hand-built raw SQL
save = load current row, diff changed columns, cascade, topo-sortA real unit-of-work per call; UPDATEs touch only changed columns; graph persistenceExtra SELECTs per save; no session identity map; cascades can reach further than expected
Async Promise + thrown errorsIdiomatic JS; integrates with async/await everywhereNo effect value, no typed/retryable error channel (effect-first); classification needs driver error
Lazy relations via Promise-typed propertiesErgonomic on-demand loading with plain awaitClassic N+1 foot-gun; loading strategy must be chosen deliberately
Code-first migrations generated from the entity↔DB diffSingle source of truth in the models; migration:generate authors DDL for youGenerated SQL needs review; no in-tree db-first scaffolding; synchronize is dev-only

Sources