Les TypeConverters Room en 2026 : stocker enums, dates et listes sans corrompre votre schéma
Un guide pratique des TypeConverters Room sur Android — enums, Instant/LocalDate, et listes — ainsi que les erreurs qui transforment un converter en bug silencieux de corruption de données.
SQLite ne connaît que cinq classes de stockage : NULL, INTEGER, REAL, TEXT, BLOB. Presque rien dans le modèle de données d’une vraie application ne ressemble à ça. Un abonnement récurrent a un enum Frequency pour la facturation. Un article de garde-manger a une date d’expiration. Une entrée budgétaire peut porter une liste de tags. Le @TypeConverter de Room est le pont entre ces deux mondes, et c’est un bout de code suffisamment petit pour qu’on l’écrive une fois, qu’on arrête d’y penser, et qu’on se retrouve deux ans plus tard avec un converter qui redéfinit silencieusement le sens d’une colonne.
Je maintiens des converters sur trois applications local-first — les dates de facturation récurrente de Subly, le suivi d’expiration de Stocky, et les enums de catégorie dans Granyn — et les bugs que j’ai réellement mis en production venaient tous de la même poignée d’erreurs. Voici comment les éviter.
La forme de base
Un converter est une paire de fonctions pures, enregistrées sur la base de données :
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()
C’est tout le contrat : une fonction par direction, par type. Room les appelle automatiquement dès que le type d’un champ d’entité n’est pas un type qu’il comprend nativement. Le piège se cache dans ce qui ressemble à une implémentation raisonnable pour chaque type.
Enums : stockez le nom, jamais l’ordinal
Enum.ordinal est tentant parce que c’est un Int et que Room le stocke nativement sans aucun code de conversion. C’est aussi une mine : l’ordinal n’est que la position de l’enum dans le fichier source. Réordonnez les cas de Frequency, ou insérez QUARTERLY entre MONTHLY et YEARLY, et chaque ligne existante pointe silencieusement vers la mauvaise valeur. Rien ne lève d’exception. Le bug, c’est un abonnement facturé chaque semaine qui apparaît comme annuel, découvert par un utilisateur, pas par un test.
enum class Frequency { WEEKLY, MONTHLY, QUARTERLY, YEARLY }
@TypeConverter
fun fromFrequency(value: Frequency): String = value.name
@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
Stocker value.name en TEXT coûte quelques octets de plus par ligne et rend le tout immunisé contre les réordonnancements. Le seul vrai danger qui reste, c’est de renommer un cas — faites-le avec une migration manuelle qui réécrit les chaînes stockées, de la même manière que vous géreriez tout autre changement de données au niveau d’une colonne.
Dates : choisissez une représentation et ne stockez jamais l’heure locale
Le bug classique des dates avec Room n’est pas le converter lui-même, c’est l’incohérence : une partie du code convertit un LocalDateTime en supposant l’heure locale de l’appareil, une autre suppose UTC, et une date correcte pour un utilisateur à Istanbul se retrouve décalée de plusieurs heures pour ce même utilisateur après un vol qui traverse des fuseaux horaires. Pour tout ce qui doit se comparer ou se trier correctement entre appareils et fuseaux horaires, stockez un instant, pas une date-heure locale :
@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()
@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
Un Long d’epoch-millis en INTEGER se trie correctement en SQL brut (utile pour ORDER BY et les requêtes par intervalle sans charger les lignes dans Kotlin), et il n’a aucune ambiguïté de fuseau horaire ancrée dans la valeur stockée — le fuseau horaire ne compte qu’au moment de l’affichage, dans la couche UI, là où est sa place. Si le champ est véritablement une date calendaire sans composante horaire — une date d’expiration sur un article de garde-manger, par exemple — stockez-la comme une chaîne TEXT ISO-8601 (2026-09-14) à la place. Elle reste triable en tant que chaîne, et cela évite la fausse précision d’un timestamp pour quelque chose qui n’a jamais été un instant précis.
Listes et collections : sachez à quoi vous renoncez
Stocker une List<String> signifie généralement la sérialiser en JSON dans le converter :
@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)
@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)
Cela fonctionne, et pour de petites listes rarement interrogées — une poignée de tags libres sur une entrée budgétaire — c’est le choix pragmatique. Mais c’est un compromis que vous faites délibérément, pas une commodité gratuite : une valeur JSON dans une colonne est opaque pour SQL. Vous ne pouvez pas faire de WHERE sur un tag, vous ne pouvez pas l’indexer, vous ne pouvez pas le joindre avec d’autres données. Dès qu’une « liste de choses » doit être interrogée, filtrée ou reliée à d’autres données, ce n’est plus un problème de converter — c’est une table manquante. Une vraie @Relation avec une table de jointure coûte plus de configuration au départ et se rentabilise dès la première fois qu’une requête a besoin de « tous les abonnements taggés work » plutôt que de « tous les abonnements, puis filtrer les tags en Kotlin ».
Deux règles qui rattrapent la plupart des bugs de converter avant qu’ils ne partent en production
Les converters doivent être purs et totaux. Pas d’I/O, pas de Clock.System.now(), pas de levée d’exception sur une entrée inattendue si vous pouvez l’éviter — un converter qui lève une exception sur une valeur stockée par une ancienne version de l’app transforme une seule ligne corrompue en crash à chaque lancement qui touche cette table. Préférez un repli sûr (un cas d’enum par défaut, une date nulle) à une exception levée pour tout ce qui lit des données existantes.
Le format de stockage d’un converter fait partie de votre schéma, même si l’export de schéma de Room ne le voit pas. AutoMigration compare les types et noms de colonnes — il n’a aucune idée que vous avez changé un converter qui stockait un Instant en millis pour le stocker sous forme de chaîne ISO. Ce changement demande le même traitement de migration manuelle et de MigrationTestHelper que tout autre remodelage de données stockées, parce que du point de vue de SQLite, une colonne TEXT ne sait pas qu’elle signifiait autrefois autre chose.
Faites bien ces deux choses, gardez les enums stockés sous forme de noms, gardez les timestamps en epoch-millis UTC, et privilégiez une vraie table plutôt que du JSON, et la couche des converters cesse d’être l’endroit où se cachent les bugs.
// À lire aussi
D’autres notes du journal
Les index de base de données Room en 2026 : trouver la requête réellement lente et la corriger
Un guide pratique pour indexer une base Room/SQLite sur Android — lire EXPLAIN QUERY PLAN, ajouter @Index sans deviner, et les erreurs qui neutralisent un index en silence.
@Relation de Room : interroger des données un-à-plusieurs sur Android sans requêtes N+1
Un guide pratique de l'annotation @Relation de Room — modéliser des données un-à-plusieurs comme des catégories et des entrées sans requêtes N+1 ni jointures manuelles.
La recherche plein texte dans Room : ajouter une recherche instantanée à une app Android local-first en 2026
Un guide pratique du support FTS4 de Room sur Android — construire une table virtuelle de recherche, la synchroniser avec des triggers, et pourquoi FTS5 exige une migration manuelle.