TypeORMは、TypeScriptのクラスとデコレーターでテーブルを定義し、SQLを書かずにデータベースを読み書きできるNode.js向けのORM(オブジェクト関係マッピング)ライブラリです。2026年5月19日に1.0が公開され、長く非推奨だった Connection 系のAPIや sqlite3 ドライバが削除されました。2026年10月20日時点のnpmの最新版は1.1.2(10月8日公開)で、各版の変更内容はGitHubのリリース一覧で確認できます。
この記事では、TypeORM 1.1.2を実際にインストールし、エンティティ定義、保存・更新・削除、リレーション、マイグレーション、QueryBuilder、トランザクションまでを動かした手順を、そのときのコードと発行されたSQLつきで説明します。検証環境はNode.js v26.5.0、TypeScript 7.0.2、better-sqlite3 12.11.1です。
まとめ:TypeORMの位置づけと1.x系で押さえる要点
- 何をするものか:TypeScriptのクラスをテーブルに対応させるORMです。Data MapperとActive Recordの両方の書き方に対応しています。
- 最新版:npmのlatestは1.1.2です。0.3系は
legacyタグで0.3.31が公開されています。 - 開発状況:2024年末にメンテナが交代し、2025年は575件のプルリクエストがマージされました。「開発が止まっている」という以前の評判は当てはまりません。
- 1.0の破壊的変更:Node.js 20以上が必須になり、
createConnectionなどのグローバル関数、sqlite3ドライバ、TYPEORM_*環境変数による設定が削除されました。where条件にnullやundefinedを渡すと例外が出ます。 - 保存のメソッド:主キー付きの
save()は既存行を探すSELECTを先に発行し、配列のsave()は1行ずつINSERTします。新規の大量データはinsert()、一意キーで「あれば更新」ならupsert()を使います。 - 本番のスキーマ変更:
synchronize: trueは使わず、マイグレーションを生成して適用します。
TypeORMの定義と特徴
TypeORMはMITライセンスのオープンソースで、GitHubのスター数は2026年10月20日にGitHub APIで取得した値で36,662です。クラスに @Entity() を付けるとテーブル、プロパティに @Column() を付けるとカラムになります。取得結果はそのクラスのインスタンスとして返るため、エディタの補完と型チェックがそのまま効きます。ORMという仕組み全般はORMの仕組みとメリット・デメリットの解説で扱っているので、ここではTypeORMならではの特徴に絞ります。
Data MapperとActive Recordの両対応
Data Mapper方式では、エンティティはデータを持つだけのクラスにして、保存や検索は dataSource.getRepository(User) で取得したリポジトリから呼びます。Active Record方式では、エンティティが BaseEntity を継承し、クラス自身が操作メソッドを持ちます。
@Entity()
export class Tag extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ unique: true })
name: string
}
// TagをDataSourceのentitiesに登録し、initialize()とマイグレーションを済ませたあと、async関数の中で実行する
await Tag.create({ name: "orm" }).save()
const found = await Tag.findOneBy({ name: "orm" })
このコードは INSERT INTO "tag" と SELECT ... WHERE "Tag"."name" = ? LIMIT 1 を発行し、found は Tag のインスタンスとして返りました。公式ドキュメントは、Data Mapperは大規模なアプリの保守性に、Active Recordは小規模なアプリの簡潔さに向くと説明しています。テストでリポジトリを差し替えたい場合や、NestJSで依存性注入を使う場合はData Mapperを選びます。この記事の以降のコードもData Mapperで書いています。
対応データベースとドライバ
1.1.2の package.json では、次のドライバがオプションの依存(peerDependencies)として宣言されています。使うデータベースのドライバだけを追加でインストールします。
| データベース | DataSourceのtype | ドライバと対応版 |
|---|---|---|
| PostgreSQL | postgres | pg ^8.5.1 |
| MySQL・MariaDB | mysql | mysql2 ^3.15.3 |
| SQLite | better-sqlite3 | better-sqlite3 ^12.0.0 |
| SQL Server | mssql | mssql ^12.0.0 |
| Oracle | oracle | oracledb ^6.3.0 または ^7.0.0 |
| MongoDB | mongodb | mongodb ^7.0.0 |
このほかSAP HANA、Google Cloud Spanner、ブラウザ向けのsql.jsにも対応しています。MySQLの旧クライアント mysql は1.0で対象外になり、mysql2 だけが使えます。
TypeORMの開発体制と1.0までの経緯
TypeORMは2016年にUmed Khudoiberdiev氏が作ったライブラリです。2024年10月1日の公式ブログ「The Future of TypeORM」は、ここ数年で保守が大きく滞り、将来への不安が広がっていたと認めています。公式のMaintainersページによると、2024年末に保守がDavid Höck氏とMichael Bromley氏へ引き継がれ、現在は13人のメンテナが名前を連ねています。
1.0の公開告知には、引き継ぎ後の数字が載っています。2025年はパッチ版を8回出し、575件のプルリクエストをマージし(前年は63件)、2,300件を超えるissueを閉じました。1.0の開発には363コミット、53人が関わっています。開発資金はOpenCollectiveのスポンサーで集めています。
npmの公開履歴を見ても、1.0以降は1.1.0(7月13日)、1.1.1(9月1日)、1.1.2(10月8日)と1〜2か月ごとに正式版が出ており、nightly版は2026年10月1日から20日まで毎日公開されていました。「保守されているか」への2026年10月時点の答えは「保守されている」です。
TypeORM 1.0で変わった点と0.3系からの移行
1.0は、0.3系で非推奨になっていたAPIをまとめて削除したリリースです。公式の移行ガイド(Upgrading from 0.3 to 1.0)から、影響を受けやすいものを抜き出しました。
| 0.3系の書き方 | 1.x系での扱い |
|---|---|
| Node.js 16・18 | 非対応(Node.js 20以上) |
| createConnection・getConnection | 削除。new DataSource()を使う |
| Connection・ConnectionOptions型 | 削除。DataSource・DataSourceOptions |
| TYPEORM_URLなどの環境変数、ormconfig.env | 削除。TS・JSの設定ファイルで書く |
| type: “sqlite” | 削除。type: “better-sqlite3” |
| findByIds([1, 2, 3]) | 削除。findBy({ id: In([1, 2, 3]) }) |
| exist() | exists()に改名 |
| @EntityRepository・getCustomRepository | 削除。Repository.extend() |
| findのjoinオプション | 削除。relationsかQueryBuilder |
| 文字列配列のselect・relations | 削除。オブジェクト形式で書く |
TypeORMは .env ファイルを自動で読み込まなくなり、dotenv への依存もなくなりました。接続情報を環境変数から渡す場合は、設定ファイルの中で process.env を自分で参照します。
where条件のnull・undefinedが例外になる変更
移行ガイドが「実行時に既存のアプリを壊しうる大きな変更」と明記しているのが、invalidWhereValuesBehavior の既定値の変更です。0.3系では findBy({ id: undefined }) のようにundefinedを渡すと条件が黙って無視され、全件が返っていました。1.x系ではnullとundefinedのどちらも例外になります。1.1.2で findBy({ nickname: undefined }) と findBy({ nickname: null }) を実行すると、次の出力になりました。
TypeORMError Undefined value encountered in property 'User.nickname' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.
TypeORMError Null value encountered in property 'User.nickname' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.
NULLの行を探すときは IsNull() 演算子を使います。検索フォームの未入力項目をそのままwhereに渡しているコードは、この変更で例外を出すようになります。その項目で絞り込まない場合は、条件を組み立てる側で項目をオブジェクトから除いてください。未入力を IsNull() に置き換えると、NULLの行だけを探す別の検索になります。どうしても旧挙動に戻す場合は、DataSourceに次の設定を加えます。
new DataSource({
// ...
invalidWhereValuesBehavior: {
null: "ignore",
undefined: "ignore",
},
})
この設定の対象はfind系メソッド、リポジトリとEntityManagerの更新系メソッド、setFindOptions() です。QueryBuilderの .where() には影響しません。また1.1.0からは、update() や delete() に空の条件を渡すと拒否されます。1.1.2でも update({}, { title: "x" }) は Empty criteria(s) are not allowed for the update method.、delete({}) は同じ文言の delete method で止まりました。条件の渡し忘れによる全件更新・全件削除を防ぐための変更です。
codemodによる自動移行とNestJSの対応版
公式の @typeorm/codemod を使うと、importの書き換え、APIの置き換え、findオプションの書式変更、依存パッケージの更新を自動で行えます。--dry を付けると、ファイルを書き換えずに変更内容だけを確認できます。自動化できない箇所には TODO コメントが残ります。
npx @typeorm/codemod v1 src/
NestJSで @nestjs/typeorm を使っている場合、v10とv11.0.0は起動時に削除済みの Connection クラスを登録しようとして落ちます。移行ガイドはv11.0.1以降への更新を求めています。2026年10月20日時点の最新版は12.0.2(9月28日公開)で、peerDependenciesは typeorm: ^0.3.0 || ^1.0.0-dev、@nestjs/core: ^10.0.0 || ^11.0.0 || ^12.0.0 です。
TypeORM 1.1.2のインストールと初期設定
1.1.2が求めるNode.jsは ^20.19.0 || ^22.13.0 || >=24.11.0 です。Node.jsの版の選び方はNode.jsの仕組みと版の選び方で整理しています。
パッケージの導入
本体、デコレーターのメタデータを扱う reflect-metadata、使うデータベースのドライバを入れます。検証ではSQLiteを使いました。
npm install [email protected] [email protected] [email protected]
npm install -D [email protected] @types/node@22
npm 11.17.0では、better-sqlite3のインストール時に、allowScripts で承認されていないインストールスクリプトがあるという npm warn allow-scripts の警告が出ました。この警告が出た状態でもネイティブモジュールはビルドされ、ドライバを読み込めました。警告を消すには npm approve-scripts better-sqlite3 で承認し、package.json の allowScripts に記録します。
エディタで Cannot find module 'typeorm' or its corresponding type declarations. が出る場合は、typeormが node_modules に入っているか、tsconfig.jsonの module の設定とimportの書き方が合っているかを確認します。
tsconfig.jsonの必須オプション
デコレーターを使うため、experimentalDecorators と emitDecoratorMetadata を有効にします。TypeScript 7.0.2でコンパイルするには、TypeORMの公式手順に載っていない設定が3つ必要でした。1つ目は rootDir で、指定しないとエラーTS5011で止まります。2つ目は types: ["node"] で、指定しないとTypeORMの型定義の中で Buffer や tls が見つからないというエラーが出ます。3つ目は lib の ESNext.Disposable で、AsyncDisposable が見つからないというエラーを解消します。skipLibCheck は使わず、次の設定でコンパイルを通しました。
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["ES2023", "ESNext.Disposable"],
"types": ["node"]
},
"include": ["src"]
}
strictPropertyInitialization を無効にしているのは、エンティティのプロパティに初期値を書かないためです。有効のまま使う場合は、各プロパティに ! を付けます。
DataSourceの設定ファイル
接続設定は DataSource のインスタンスとして書き、アプリとCLIの両方から読み込みます。
import "reflect-metadata"
import { DataSource } from "typeorm"
import { User } from "./entities/User"
import { Post } from "./entities/Post"
export const AppDataSource = new DataSource({
type: "better-sqlite3",
database: "app.sqlite",
entities: [User, Post],
migrations: ["dist/migrations/*.js"],
synchronize: false,
logging: ["query", "error"],
})
ファイルの配置は src/data-source.ts、src/entities/User.ts、src/entities/Post.ts です。logging に "query" を入れると、発行されたSQLがそのまま出力されます。以降の章のSQLは、この設定で記録したものです。
エンティティ定義で使うデコレーター
ユーザーと投稿の2テーブルを定義します。
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from "typeorm"
import { Post } from "./Post"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({ length: 100, unique: true })
email: string
@Column({ type: "varchar", nullable: true })
nickname: string | null
@CreateDateColumn()
createdAt: Date
@OneToMany(() => Post, (post) => post.author)
posts: Post[]
}
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"
import { User } from "./User"
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column({ default: false })
published: boolean
@ManyToOne(() => User, (user) => user.posts, { nullable: false })
author: User
}
ここで使ったデコレーターと、マイグレーションで実際に生成されたDDLの対応は次のとおりです。
| デコレーターとオプション | SQLiteで生成された定義 |
|---|---|
| @PrimaryGeneratedColumn() | integer PRIMARY KEY AUTOINCREMENT NOT NULL |
| @Column({ length: 100, unique: true }) | varchar(100) NOT NULL + UNIQUE制約 |
| @Column({ type: “varchar”, nullable: true }) | varchar(NULL許可) |
| @Column({ default: false }) | boolean NOT NULL DEFAULT (0) |
| @CreateDateColumn() | datetime NOT NULL DEFAULT (datetime(‘now’)) |
| @ManyToOne(…, { nullable: false }) | authorId integer NOT NULL + 外部キー |
@Column() は、オプションを省くとNOT NULLになります。NULLを許すカラムは nullable: true を明示してください。string | null のようなユニオン型は、デコレーターのメタデータから列の型を推定できません。そのため type も指定しています。type を省くと、初期化時に DataTypeNotSupportedError(Data type "Object" in "Bad.displayName" is not supported by "better-sqlite3" database.)で止まりました。
エンティティはDataSourceの entities に登録します。登録を忘れたクラスでクエリを実行すると、EntityMetadataNotFoundError(No metadata for "Tag2" was found.)が出ます。entities にglobパターンで .ts を指定したまま、コンパイル後の .js で起動した場合もこのエラーになります。NestJSでAPIの入出力にエンティティを直接使わず、DTOを分けて定義する考え方はDTOの意味とEntityとの違いで扱っています。
save・insert・upsert・update・deleteの使い分け
リポジトリの書き込み系メソッドは、名前が似ていても発行するSQLの本数と、重複時の振る舞いが違います。後述の main() の中で const repo = AppDataSource.getRepository(User) と const posts = AppDataSource.getRepository(Post) を用意し、1.1.2とbetter-sqlite3で実行したときのSQLを並べました。id と createdAt はDBが値を決める列なので、INSERTのあとにその値を読むSELECTが続きます。
| メソッド | 発行されたSQL | 重複キーのとき |
|---|---|---|
| save(主キーなし) | INSERT → 生成列を読むSELECT | 例外 |
| save(配列2件) | 1件ずつINSERT+SELECT(計4本) | 例外 |
| save(主キー付き) | SELECT → 変わった列だけUPDATE | – |
| save(変更なし) | SELECTのみ | – |
| insert(配列2件) | 複数行INSERT 1本 → SELECT 1本 | QueryFailedError |
| upsert | INSERT … ON CONFLICT DO UPDATE → SELECT | 更新される |
| update・delete | 条件つきUPDATE・DELETE 1本 | – |
saveの事前SELECTと配列保存の発行本数
公式のRepository APIは、save() を「DBに存在すれば更新、無ければ挿入」と説明し、undefinedのプロパティは更新対象から外れると書いています。主キーだけを付けた部分オブジェクトを渡すと、次のSQLが流れました。
await repo.save({ id: 2, nickname: "cc" })
SELECT "User"."id" AS "User_id", "User"."email" AS "User_email", ... FROM "user" "User" WHERE "User"."id" = 2
BEGIN TRANSACTION
UPDATE "user" SET "nickname" = ? WHERE "id" = 2 -- PARAMETERS: ["cc"]
COMMIT
既存行を読み、差分のある nickname だけを更新しています。読み込んだエンティティを何も変えずに save() に渡したときはSELECTだけが流れ、UPDATEは発行されませんでした。主キーを持たない新規の保存では、この事前SELECTは出ません。
本数の差が出るのは配列です。2件の配列を save() に渡すと、トランザクションの中で1件ずつINSERTと生成列のSELECTを繰り返し、計4本になりました。同じ2件を insert() に渡すと、VALUES (...), (...) の複数行INSERT 1本と、2件の id をまとめて読むSELECT 1本で済みました。件数が増えるほど往復の差は開くので、新規と分かっている大量データは insert() に配列で渡します。主キーと変更列が分かっている更新は、update(条件, 値) で1本のUPDATEにできます。
insertとupsertの重複キーでの違い
メールアドレスに一意制約がある User へ、既にある [email protected] を insert() すると、QueryFailedError SqliteError: UNIQUE constraint failed: user.email で失敗しました。同じ値を upsert() に渡すと、衝突したら更新する書き込みが1本で発行され、そのあと生成列を読むSELECTが続きました。書き込み部分は次のとおりです。
await repo.upsert({ email: "[email protected]", nickname: "upserted" }, ["email"])
INSERT INTO "user"("id", "email", "nickname", "createdAt") VALUES (NULL, ?, ?, datetime('now')) ON CONFLICT ( "email" ) DO UPDATE SET "email" = EXCLUDED."email", "nickname" = EXCLUDED."nickname"
第2引数には、衝突を判定する一意制約の列を渡します。公式ドキュメントが upsert() の対応ドライバとして挙げるのはAurora Data API、CockroachDB、MySQL、PostgreSQL、SQLiteで、SQL ServerとOracleは含まれません。SQL ServerやOracleでは、存在を確かめてから insert() か update() を呼ぶ形になりますが、これだけでは同時に動く2つの処理がどちらも「無い」と判断して、片方が一意制約違反になります。一意制約違反を捕まえて update() でやり直す処理を入れるか、各DBのMERGE文やロックの仕組みをQueryBuilderや生SQLで使ってください。
softDeleteによる論理削除と復元
行を消さずに削除済みとして扱うには、エンティティに @DeleteDateColumn() を足します。Post に deletedAt: Date | null を追加して実行した結果です。
await posts.softDelete({ title: "下書き" })
// UPDATE "post" SET "deletedAt" = CURRENT_TIMESTAMP WHERE "title" = ? AND "deletedAt" IS NULL
await posts.count() // 1(削除済みを除外)
await posts.count({ withDeleted: true }) // 2
await posts.restore({ title: "下書き" })
// UPDATE "post" SET "deletedAt" = NULL WHERE "title" = ? AND "deletedAt" IS NOT NULL
列を足したあとは、find系メソッドとQueryBuilderのSQLに "deletedAt" IS NULL が自動で付きました。relations で投稿を読み込むJOINの条件にも入ります。削除済みを含めて集計する管理画面では withDeleted: true を忘れると数が合いません。一意制約は削除済みの行にも効くので、論理削除したユーザーと同じメールアドレスでは再登録できない点にも注意してください。
リレーションの設定方法
リレーションは、片方のエンティティだけにデコレーターを付ける単方向と、両方に付けて互いに参照できる双方向のどちらでも定義できます。1対1と多対1では外部キーを持つ側、多対多では @JoinTable を付けた側を「所有側(owner)」と呼びます。多対多の外部キーは、エンティティのテーブルではなく中間テーブルに作られます。
| 関係 | 所有側に付けるもの | 反対側 |
|---|---|---|
| 多対1・1対多 | @ManyToOne(外部キーを自動で作成) | @OneToMany |
| 1対1 | @OneToOne + @JoinColumn | @OneToOne |
| 多対多 | @ManyToMany + @JoinTable | @ManyToMany |
ManyToOneとOneToManyの対応
先ほどの Post.author(@ManyToOne)と User.posts(@OneToMany)は、第2引数で互いのプロパティを指し合っています。外部キー列 authorId は所有側の post テーブルに作られます。公式ドキュメントのとおり、@OneToMany は反対側の @ManyToOne がなければ使えません。逆に @ManyToOne だけを定義することはできます。relations: { posts: true } でユーザーを取得すると、次のように LEFT JOIN で投稿が読み込まれました(列リストは省略)。
SELECT ... FROM "user" "User" LEFT JOIN "post" "User__User_posts" ON "User__User_posts"."authorId"="User"."id" WHERE (("User"."nickname" IS NULL))
nullable: falseでINNER JOINになる挙動
1.0からは、nullable: false を付けた @ManyToOne と、所有側の @OneToOne を relations で読み込むと INNER JOIN が使われます。1.1.2でも、Post を relations: { author: true } で取得したSQLは INNER JOIN "user" "Post__Post_author" でした。外部キーがNOT NULLで、参照先の行が必ず存在するなら結果は変わりません。ただし、実際のテーブルでは外部キーがNULL許可のままになっている場合や、参照先の無い行が残っている場合は、その行が結果から消えます。移行時は、エンティティの nullable とDBの実際の定義が一致しているかを確認してください。関連先に @DeleteDateColumn がある場合は例外で、LEFT JOIN のままです。
OneToOneとManyToManyの所有側
1対1では、@JoinColumn() を外部キーを持つ側だけに付けます。公式ドキュメントはこれを必須とし、両側に付けてはいけないと明記しています。多対多では @JoinTable() が必須で、これも片側だけに付けます。付けた側のエンティティ名を元に、中間テーブル(例:question_categories_category)が作られます。中間テーブルに作成日時などの列を持たせたい場合は、@ManyToMany ではなく中間エンティティを定義し、@ManyToOne を2本張る設計にします。
EagerとLazyの使い分け
eager: true を付けたリレーションは、find系メソッドで自動的に読み込まれます。ただし公式ドキュメントのとおり、QueryBuilderでは無効で、leftJoinAndSelect を書く必要があります。また、eagerはリレーションの片側にしか付けられません。Lazyリレーションは、プロパティの型を Promise<Post[]> にし、await したときに別クエリで読み込む方式です。一覧画面でLazyを使うと、行ごとにクエリが発行されるN+1問題が起きます。発行本数の実測とORM別の対策はN+1問題の発行クエリ数の実測と対策にまとめました。一覧では relations かJOINで一度に取得し、eagerとLazyは使う場所を限定します。
マイグレーションによるスキーマ管理
synchronizeを本番で使わない理由
synchronize: true は、起動のたびにエンティティとDBの差分を自動で反映します。公式のDataSourceオプションの説明には「本番では使わないこと。本番データを失う恐れがある」とあります。SQLiteで外部キーを追加する差分では、TypeORMは temporary_post テーブルを作り、データを移してから元のテーブルを DROP していました。どのような変更SQLが流れるかを事前に確認できないまま本番のテーブルが作り直されるため、開発初期だけ有効にし、共有する環境ではマイグレーションに切り替えます。
generate・run・revertのコマンド
TypeORMのCLIは typeorm コマンドで、-d にDataSourceのファイルを渡します。検証では、コンパイル済みのJavaScriptに対してCLIを実行しました。この方法ならts-nodeを入れる必要がありません。
npx tsc
npx typeorm migration:generate src/migrations/Init -d dist/data-source.js
npx tsc
npx typeorm migration:run -d dist/data-source.js
npx typeorm migration:show -d dist/data-source.js
# 取り消す場合だけ実行する(テーブルが消えるので、続けて使うなら再度runする)
npx typeorm migration:revert -d dist/data-source.js
migration:generate は、エンティティと現在のDBを比べて差分のSQLを up、元に戻すSQLを down に書いたファイルを作ります(今回は src/migrations/1792499394858-Init.ts)。差分が無いときは No changes in database schema were found - cannot generate a migration. と表示され、ファイルは作られません。空のマイグレーションを手で書く場合は migration:create を使います。migration:show は適用済みを [X] 1 Init1792499394858 のように表示します。migration:revert は最後に適用した1件の down を実行します。
生成されたSQLは、コミット前に必ず読んでください。nickname を displayName に改名する差分では、SQLiteでは temporary_user を作って INSERT INTO ... SELECT で旧列の値を移し、元の user テーブルを DROP するSQLが生成されました。データは引き継がれますが、行数の多いテーブルでは作り直しに時間がかかり、その間は書き込みができません。本番環境では、アプリの起動時に runMigrations() を自動実行するより、デプロイの手順でCLIを1回だけ実行するほうが安全です。複数台が同時に起動しても、マイグレーションが二重に走りません。既存DBの移行計画や本番での切り替え手順を外部に相談したい場合は、データベース設計・移行支援で対応しています。
データ取得のfind APIとQueryBuilder
ここからのコード片は、検証用の src/main.ts の main() 関数の中で実行したものです。出力は、app.sqlite が無い状態から初回に実行したときのものです(2回目は同じメールアドレスの保存が一意制約で止まるので、ファイルを消してから実行します)。関数の冒頭では、DataSourceを初期化してマイグレーションを適用し、[email protected] のユーザーと投稿2件をトランザクションで保存しています。
import { IsNull } from "typeorm"
import { AppDataSource } from "./data-source"
import { User } from "./entities/User"
import { Post } from "./entities/Post"
async function main() {
await AppDataSource.initialize()
await AppDataSource.runMigrations()
await AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, { email: "[email protected]", nickname: null })
await manager.save(Post, [
{ title: "TypeORM 1.1を試す", author: user },
{ title: "下書き", author: user },
])
})
// 以降のコード片はここに続けて書く
await AppDataSource.destroy()
}
main()
単純な条件の検索は、リポジトリの find 系メソッドで書きます。1.x系では、select、relations、order をオブジェクト形式で指定します。
const users = await AppDataSource.getRepository(User).find({
where: { nickname: IsNull() },
relations: { posts: true },
})
集計や複雑なJOIN、取得する列の絞り込みはQueryBuilderで書きます。値は :published のような名前付きパラメータで渡し、文字列連結でSQLに埋め込まないようにします。
const rows = await AppDataSource.getRepository(Post)
.createQueryBuilder("post")
.innerJoin("post.author", "author")
.select(["post.title AS title", "author.email AS email"])
.where("post.published = :published", { published: false })
.orderBy("post.id", "ASC")
.getRawMany()
発行されたSQLと、返ってきた結果は次のとおりです。
SELECT "post"."title" AS title, "author"."email" AS email FROM "post" "post" INNER JOIN "user" "author" ON "author"."id"="post"."authorId" WHERE "post"."published" = ? ORDER BY "post"."id" ASC -- PARAMETERS: [0]
[
{ title: 'TypeORM 1.1を試す', email: '[email protected]' },
{ title: '下書き', email: '[email protected]' }
]
getRawMany() は、エンティティではなく列名をキーにしたオブジェクトを返します。エンティティとして受け取りたい場合は、innerJoinAndSelect と getMany() を組み合わせ、独自の別名を付けた select() は外します。別名付きの select() を残したまま getMany() を呼ぶと、0件の配列が返りました。select() を外すと、author を含む Post のインスタンスが2件返りました。1.0では、orderBy に渡した値の検証と、スキーマ操作での識別子のエスケープが全ドライバに入りました。いずれもSQLインジェクション対策としてリリースノートに記載されています。
トランザクションの書き方
transaction()のコールバックとロールバック
dataSource.transaction() に渡したコールバックの中では、引数の manager を通して操作します。コールバックが例外を投げると、それまでの変更はすべてロールバックされます。メールアドレスが重複するユーザーを2件目に保存させて、ロールバックを確認しました。
try {
await AppDataSource.transaction(async (manager) => {
await manager.save(User, { email: "[email protected]" })
await manager.save(User, { email: "[email protected]" })
})
} catch (e) {
console.log("rollback:", await AppDataSource.getRepository(User).count())
}
このコードを実行すると、UNIQUE constraint failed: user.email で2件目の INSERT が失敗し、ROLLBACK が発行されました。1件目の [email protected] も残らず、ユーザー数は1件のままでした。コールバック内で AppDataSource.getRepository() を直接使うと、その操作がトランザクションに参加することは保証されません。公式ドキュメントの指示どおり、必ず manager を使ってください。分離レベルは、1.0からDataSourceの isolationLevel で全ドライバの既定値を設定できます。
await usingのQueryRunnerが例外時にコミットする挙動
1.0から、QueryRunner は await using 構文(TypeScript 5.2以降)に対応し、スコープを抜けると自動で解放されます。1.0の告知は、接続を返し忘れる不具合を減らす機能として紹介しています。ただし、解放処理はトランザクションをロールバックしません。1.1.2の BaseQueryRunner の [Symbol.asyncDispose]() は、トランザクションが開いていれば commitTransaction() を呼んでから release() します。途中で例外を投げると、次のようになりました。
try {
await using qr = AppDataSource.createQueryRunner()
await qr.startTransaction()
await qr.manager.save(User, { email: "[email protected]" })
throw new Error("途中で失敗")
} catch (e) {
// BEGIN TRANSACTION → INSERT → COMMIT の順に発行された
}
await AppDataSource.getRepository(User).countBy({ email: "[email protected]" }) // 1
例外で中断したのに、INSERT した行はコミットされて残りました。この挙動はissue #12913(2026年9月30日起票・10月20日時点でopen)で、解放時にロールバックを選べる設定の追加が提案されています。公式ドキュメントの await using の例はトランザクションを使っていないため、この点は書かれていません。
途中で失敗したら取り消したい処理には、await using と startTransaction() を組み合わせないでください。dataSource.transaction() を使うか、QueryRunner を手で扱う場合は try で commitTransaction()、catch で rollbackTransaction()、finally で release() を呼ぶ従来の書き方にします。await using は、読み取りだけや、1文ずつ確定してよい処理での解放漏れ防止に限って使います。
よくあるエラーと対処
- No metadata for “X” was found:エンティティが
entitiesに登録されていない、またはglobの拡張子が実行環境(.tsか.jsか)と合っていません。 - Undefined value encountered in property …:where条件にundefinedが入っています。条件オブジェクトから項目を除くか、NULLを探すなら
IsNull()を使います。 - Empty criteria(s) are not allowed for the update method.:
update()やdelete()の条件が空です。 - UNIQUE constraint failed(QueryFailedError):
insert()か新規のsave()で一意制約に当たっています。あれば更新でよいならupsert()に切り替えます。 - createConnectionやgetConnectionが見つからない:1.x系で削除されたAPIを呼んでいます。
new DataSource()とinitialize()に書き換えます。 - sqlite3ドライバが読み込まれない:1.x系では、
type: "better-sqlite3"とbetter-sqlite3パッケージに切り替えます。オプションのbusyTimeoutはtimeoutに名前が変わりました。 - No changes in database schema were found:
migration:generateで差分が無い状態です。エンティティの変更がビルド後のJSに反映されているか確認します。
NestJSの起動時に、Connection に関連するエラーで落ちる場合は、@nestjs/typeorm が11.0.1より古い版のままになっています。
TypeORMを選ぶ条件と避けるべき場面
TypeORMが向いているのは、クラスとデコレーターでモデルを書く設計(NestJSなど)を採用している場合、SQLに近い書き方でJOINや集計を組みたい場合、PostgreSQLとOracleのように複数のDBを同じ書き方で扱いたい場合です。既に0.3系で動いているNestJSのアプリなら、別のORMへ書き換える前に、codemodで1.x系へ上げて残る TODO の量を確かめるところから始めます。
避けるべきなのは、型安全を最優先する場合です。getRawMany() の戻り値や、QueryBuilderに文字列で書いた列名は、コンパイル時に検査されません。スキーマファイルから型を生成するPrismaや、SQLに近い書き方で結果の型まで推論するDrizzleのほうが、列名の打ち間違いをコンパイル時に見つけられます。PrismaとTypeORMの比較はTypeORMとPrismaの比較記事で、Drizzleの特徴はDrizzle ORMの使い方とv1.0 RCの変更点で詳しく扱っています。
よくある質問
TypeORMとは何ですか?
TypeScriptのクラスとデコレーターでテーブルを定義し、リポジトリやQueryBuilderでDBを操作するNode.js向けのORMです。PostgreSQL、MySQL、SQLite、SQL Server、Oracle、MongoDBなどに対応しています。
TypeORMの最新バージョンはいくつですか?
2026年10月20日時点で、npmのlatestは1.1.2(2026年10月8日公開)です。0.3系は legacy タグで0.3.31が公開されています。最新の版は npm view typeorm dist-tags で確認できます。
0.3系のコードは1.x系でそのまま動きますか?
非推奨APIを使っていなければ、多くはそのまま動きます。ただし、where条件のnull・undefinedが例外になる変更と、nullable: false のリレーションがINNER JOINになる変更は、コンパイルでは検出できず実行時に表面化します。npx @typeorm/codemod v1 src/ で書き換えたうえで、テストを実行してください。
NestJSでTypeORM 1.xを使うには何が必要ですか?
@nestjs/typeorm をv11.0.1以降に上げます。v10とv11.0.0は起動時に落ちます。NestJS 12と組み合わせる場合は、@nestjs/core の12系をpeerDependenciesに含む12.0.0以降を使います。環境変数から接続情報を読む設定は、@nestjs/config(ConfigModule)の使い方を参考にしてください。
saveとinsertはどちらを使えばよいですか?
新規か既存か分からないエンティティを保存するなら save() です。新規と分かっている大量データは insert() に配列で渡します。検証では2件の保存が save() で4本、insert() で2本のSQLになりました。一意キーが重複したら更新したいときは upsert() を使います。