2026年のRoom TypeConverters: enum・日付・リストをスキーマを壊さずに保存する
AndroidのRoom TypeConvertersに関する実践ガイド — enum、Instant/LocalDate、そしてリスト — さらに、コンバーターを静かなデータ破損バグに変えてしまう間違いについて。
SQLiteが知っているストレージクラスは NULL、INTEGER、REAL、TEXT、BLOB の5つだけです。実際のアプリのデータモデルで、これにそのまま当てはまるものはほとんどありません。定期購読には請求頻度を表す Frequency enumがあります。パントリーのアイテムには賞味期限があります。予算の項目にはタグのリストが付くこともあります。Roomの @TypeConverter はこの二つの世界をつなぐ橋であり、十分に小さなコードなので、人々は一度書くとそれについて考えるのをやめてしまい、2年後には列の意味を静かに歪めるコンバーターができあがっています。
私は3つのローカルファーストアプリでコンバーターを保守しています — Sublyの定期課金日、Stockyの賞味期限追跡、Granynのカテゴリenumです — そして実際に出荷してしまったバグは、すべて同じ一握りの間違いから来ていました。ここでは、それを避ける方法を説明します。
基本の形
コンバーターは、データベースに登録される一対の純粋関数です。
class Converters {
@TypeConverter
fun fromFrequency(value: Frequency): String = value.name
@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
}
@Database(entities = [Subscription::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
これが契約のすべてです。方向ごと、型ごとに一つの関数。Roomはエンティティのフィールドの型がネイティブに理解できないものであるときは常に、これらを自動的に呼び出します。罠は、それぞれの型に対して「妥当に見える実装」の中に潜んでいます。
Enum: ordinalではなく、常にnameを保存する
Enum.ordinal は Int であり、Roomが変換コードなしにネイティブに保存してくれるため魅力的に見えます。しかしこれは地雷でもあります。ordinalは単に、ソースファイル内でのenumの位置に過ぎません。Frequency のケースを並べ替えたり、MONTHLY と YEARLY の間に QUARTERLY を挿入したりすると、既存のすべての行が静かに間違った値を指すようになります。何も例外は投げられません。バグは、テストではなくユーザーによって発見される、「毎週課金されるはずの定期購読が年次として表示される」という形を取ります。
enum class Frequency { WEEKLY, MONTHLY, QUARTERLY, YEARLY }
@TypeConverter
fun fromFrequency(value: Frequency): String = value.name
@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
value.name を TEXT として保存するのは、1行あたり数バイト余分にかかるだけで、並べ替えの影響を受けません。残る本当の危険は、ケースの 名前を変える ことです — それを行うときは、保存されている文字列を書き換える手動マイグレーションを、他の列レベルのデータ変更と同じように扱ってください。
日付: 一つの表現方式を選び、ローカル時刻は決して保存しない
Roomの日付にまつわる典型的なバグは、コンバーター自体ではなく、一貫性のなさです。コードベースのある部分は端末のローカル時刻を前提に LocalDateTime を変換し、別の部分はUTCを前提にしていて、イスタンブールのユーザーにとっては正しい日付が、タイムゾーンをまたぐフライトの後には同じユーザーにとって数時間ずれてしまいます。デバイスやタイムゾーンをまたいで正しく比較・ソートする必要があるものについては、ローカルな日時ではなく、瞬間(instant)を保存してください。
@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()
@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
エポックミリ秒の Long を INTEGER として保存すると、生のSQLで正しくソートでき(行をKotlinに読み込む前に ORDER BY や範囲クエリを行う際に便利です)、保存された値にタイムゾーンの曖昧さが組み込まれることもありません — タイムゾーンが関係するのは表示時、つまりUI層だけであり、それが本来あるべき場所です。もしそのフィールドが本当にカレンダー上の日付であって時刻の要素を持たないもの — たとえばパントリーアイテムの賞味期限 — であれば、代わりにISO-8601形式の TEXT 文字列(2026-09-14)として保存してください。文字列としても正しくソートでき、時刻を持たなかったものにタイムスタンプの偽の精度を持たせずに済みます。
リストとコレクション: 何を諦めているかを知る
List<String> を保存するというのは、たいていコンバーターの中でJSONにシリアライズすることを意味します。
@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)
@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)
これは動作しますし、小さくてめったにクエリされないリスト — 予算項目に付く数個の自由入力タグなど — であれば実用的な選択です。しかしこれは意図的に受け入れているトレードオフであって、無料の便利機能ではありません。列に入ったJSONの値はSQLにとって不透明です。タグに対して WHERE を使うことも、インデックスを張ることも、結合することもできません。「ものごとのリスト」がクエリされたり、フィルタされたり、他のデータと関連付けられたりする必要が出てきた瞬間、それはもうコンバーターの問題ではなく、テーブルが足りていないという問題です。適切な結合テーブルを伴う @Relation は、最初のセットアップにより多くの手間がかかりますが、「work というタグが付いたすべての定期購読」を必要とするクエリが最初に現れたとき — 「すべての定期購読を取得してからKotlin側でタグをフィルタする」ではなく — その手間はすぐに元が取れます。
出荷前にほとんどのコンバーターのバグを捕まえる2つのルール
コンバーターは純粋かつ全域(total)でなければなりません。 I/Oなし、Clock.System.now() なし、可能な限り予期しない入力に対して例外を投げないこと — 古いバージョンのアプリが保存した値に対して例外を投げるコンバーターは、たった1行の壊れたデータを、そのテーブルに触れるすべての起動時のクラッシュに変えてしまいます。既存データを読み取るものについては、例外を投げるより、安全なフォールバック(デフォルトのenumケース、null日付)を優先してください。
コンバーターのストレージフォーマットは、Roomのスキーマエクスポートには見えないとしても、あなたのスキーマの一部です。 AutoMigration は列の型と名前を差分比較しますが、あなたがコンバーターを「Instant をミリ秒として保存する」ものから「ISO文字列として保存する」ものに変更したことなど知る由もありません。その変更は、保存データの他のあらゆる再構成と同じように、手動マイグレーションと MigrationTestHelper による検証を必要とします。なぜならSQLiteの視点から見れば、TEXT 列はそれがかつて別の意味を持っていたことを知らないからです。
この2つを正しく守り、enumはnameとして保存し続け、タイムスタンプはUTCのエポックミリ秒のままにし、JSONに手を伸ばす前に本物のテーブルに手を伸ばすようにすれば、コンバーター層はバグの隠れ場所ではなくなります。
// 関連記事
ジャーナルの他の記事
2026年のRoomデータベースインデックス:本当に遅いクエリを見つけて直す
AndroidでRoom/SQLiteデータベースにインデックスを張る実践ガイド — EXPLAIN QUERY PLANの読み方、勘に頼らない@Indexの追加、インデックスを静かに無効化してしまう間違い。
RoomのRelationで一対多データをN+1クエリなしに取得する
Roomの@Relationアノテーションの実践ガイド — カテゴリとエントリのような一対多データを、N+1クエリや手動のjoinなしでモデリングする。
Room の全文検索:2026 年、ローカルファーストな Android アプリに即時検索を追加する
Room の FTS4 サポートを実践的に解説 — 検索用の仮想テーブルを構築し、トリガーで同期を保ち、なぜ FTS5 には手動マイグレーションが必要なのか。