Saltar al contenido
Todas las entradas

Room TypeConverters en 2026: cómo guardar enums, fechas y listas sin corromper tu esquema

Una guía práctica sobre los TypeConverters de Room en Android — enums, Instant/LocalDate y listas — además de los errores que convierten un converter en un bug silencioso de corrupción de datos.

MFKAPPS 5 min de lectura

SQLite solo conoce cinco clases de almacenamiento: NULL, INTEGER, REAL, TEXT, BLOB. Casi nada en el modelo de datos de una app real se parece a eso. Una suscripción recurrente tiene un enum Frequency de facturación. Un artículo de despensa tiene una fecha de caducidad. Una entrada de presupuesto puede llevar una lista de etiquetas. El @TypeConverter de Room es el puente entre esos dos mundos, y es un fragmento de código tan pequeño que la gente lo escribe una vez, deja de pensar en él, y termina con un converter que dos años después reinterpreta silenciosamente el significado de una columna.

Mantengo converters en tres apps local-first — las fechas de facturación recurrente de Subly, el seguimiento de caducidad de Stocky, y los enums de categoría en Granyn — y los bugs que realmente he lanzado a producción vinieron todos del mismo puñado de errores. Aquí está cómo evitarlos.

La forma básica

Un converter es un par de funciones puras, registradas en la base de datos:

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()

Ese es todo el contrato: una función por dirección, por tipo. Room las llama automáticamente cada vez que el tipo del campo de una entidad no es uno que entienda de forma nativa. La trampa está en lo que parece una implementación razonable para cada tipo.

Enums: guarda el nombre, nunca el ordinal

Enum.ordinal resulta tentador porque es un Int y Room lo almacena de forma nativa sin código de conversión. También es una mina terrestre: el ordinal es solo la posición del enum en el archivo fuente. Reordena los casos de Frequency, o inserta QUARTERLY entre MONTHLY y YEARLY, y cada fila existente apunta silenciosamente al valor equivocado. Nada lanza una excepción. El bug es una suscripción que se factura semanalmente y aparece como anual, descubierta por un usuario, no por 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)

Guardar value.name como TEXT cuesta unos cuantos bytes extra por fila y es inmune a la reordenación. El único riesgo real que queda es renombrar un caso — hazlo con una migración manual que reescriba las cadenas almacenadas, igual que harías con cualquier otro cambio de datos a nivel de columna.

Fechas: elige una representación y nunca guardes hora local

El bug clásico de fechas en Room no está en el converter en sí, sino en la inconsistencia: una parte del código convierte LocalDateTime asumiendo la hora local del dispositivo, otra asume UTC, y una fecha que es correcta para un usuario en Estambul queda desfasada por horas para ese mismo usuario tras un vuelo que cruza zonas horarias. Para cualquier cosa que necesite compararse u ordenarse correctamente entre dispositivos y zonas horarias, guarda un instante, no una fecha-hora local:

@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()

@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)

Un Long en epoch-millis como INTEGER se ordena correctamente en SQL puro (útil para ORDER BY y consultas por rango sin tener que cargar las filas en Kotlin primero), y no tiene ninguna ambigüedad de zona horaria incorporada en el valor almacenado — la zona horaria solo importa en el momento de mostrar el dato, en la capa de UI, que es donde pertenece. Si el campo es genuinamente una fecha de calendario sin componente de hora — una fecha de caducidad en un artículo de despensa, por ejemplo — guárdala como una cadena TEXT en formato ISO-8601 (2026-09-14) en su lugar. Sigue siendo ordenable como cadena, y evita la falsa precisión de un timestamp para algo que nunca fue un instante en el tiempo.

Listas y colecciones: sabe qué estás sacrificando

Guardar una List<String> normalmente significa serializarla a JSON en el converter:

@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)

@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)

Esto funciona, y para listas pequeñas y consultadas con poca frecuencia — un puñado de etiquetas libres en una entrada de presupuesto — es la opción pragmática. Pero es una concesión que haces deliberadamente, no una comodidad gratuita: un valor JSON dentro de una columna es opaco para SQL. No puedes hacer WHERE sobre una etiqueta, no puedes indexarla, no puedes hacer join contra ella. En el momento en que una “lista de cosas” necesita consultarse, filtrarse o relacionarse con otros datos, ya no es un problema de converter — es una tabla que falta. Una @Relation apropiada con una tabla de unión cuesta más configuración inicial y se paga sola la primera vez que una consulta necesita “cada suscripción etiquetada work” en lugar de “cada suscripción, y luego filtrar etiquetas en Kotlin.”

Dos reglas que atrapan la mayoría de los bugs de converter antes de que se publiquen

Los converters deben ser puros y totales. Nada de I/O, nada de Clock.System.now(), nada de lanzar excepciones ante una entrada inesperada si puedes evitarlo — un converter que lanza una excepción ante un valor guardado por una versión anterior de la app convierte una única fila corrupta en un crash en cada lanzamiento que toque esa tabla. Prefiere un valor de respaldo seguro (un caso de enum por defecto, una fecha nula) antes que una excepción lanzada, para cualquier cosa que lea datos existentes.

El formato de almacenamiento de un converter es parte de tu esquema, aunque la exportación de esquema de Room no lo vea. AutoMigration compara tipos y nombres de columna — no tiene ni idea de que cambiaste un converter de guardar Instant como millis a guardarlo como una cadena ISO. Ese cambio necesita el mismo tratamiento de migración manual y MigrationTestHelper que cualquier otra remodelación de datos almacenados, porque desde el punto de vista de SQLite, una columna TEXT no sabe que antes significaba otra cosa.

Acierta esas dos cosas, mantén los enums guardados como nombres, mantén los timestamps como epoch-millis en UTC, y recurre a una tabla real antes que a JSON, y la capa de converters deja de ser el lugar donde se esconden los bugs.