Room is a wrapper around SQLite3 database. Consequently, out of the box, it supports the same types of data as SQLite3, namely: NULL
, INTEGER
, REAL
, TEXT
, BLOB
. This translates to null, Int
, Long
, Float
, Double
, String
and ByteArray
types in Kotlin. That’s a decent list of primitive types, but, sometimes, you’ll need to store and retrieve additional data types using Room. That’s where Room’s type converters come into the picture.
In this article, which is a fourth in my series about Room (part 1, part 2, part 3), I’ll explain what type converters do and how you can implement them in your Android apps.
Storing and Retrieving Non-Supported Data Type with Room
Let’s continue with the example Room entity that we’ve been using in the previous articles in this series:
@Entity(tableName = "appMemoryInfo") data class AppMemoryInfo( @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long, @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float, @field:ColumnInfo(name = "timestamp") val timestamp: Float, )
Since Room supports the types of all the properties in this entity out-of-the-box, it can serialize and deserialize this entity without any additional effort.
Now imagine that instead of storing just the timestamp, we need the full datetime information, including the timezone offset. In that case, we’ll need to change this entity to:
@Entity(tableName = "appMemoryInfo") data class AppMemoryInfo( @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long, @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float, @field:ColumnInfo(name = "datetime") val zonedDateTime: ZonedDateTime, )
If I build my application now, I’ll get this error:
Cannot figure out how to save this field into database. You can consider adding a type converter for it.
Room basically tells me that it doesn’t support ZonedDateTime
type and suggests that I should add a type converter for it. That’s exactly what I’m going to do.
That’s the type converter:
class ZonedDateTimeConverter { private val formatter = DateTimeFormatter.ISO_DATE_TIME @TypeConverter fun toString(entity: ZonedDateTime): String { return formatter.format(entity) } @TypeConverter fun fromString(serialized: String): ZonedDateTime { return formatter.parse(serialized) as ZonedDateTime } }
Note the following aspects:
ZonedDateTimeConverter
is a simple class which doesn’t extend any other class.- Both methods bear
@TypeConverter
annotation. This instructs Room to consider these methods when it deals with unsupported data types. - The first method takes
ZonedDateTime
as an argument and returnsString
. Room will call this method to serialize the respective data type before insertion into the database. - The second method takes
String
as an argument and returnsZonedDateTime
. Room will call this method to deserialize the respective data type before returning the result retrieved from the database.
To instruct Room to use our custom type converter, we need to register it with MyRoomDatabase
:
@Database( entities = [AppMemoryInfo::class], version = 1 ) @TypeConverters( value = [ZonedDateTimeConverter::class] ) abstract class MyRoomDatabase : RoomDatabase() { abstract val appMemoryInfoDao: AppMemoryInfoDao }
That’s it. Now I can build and run my application without problems because Room will be able to handle ZonedDateTime
instances.
The idea behind type converters is rather straightforward: Room will inspect the annotations and the argument/return types, and “remember” this information. Then, at runtime, when the unsupported type appears, Room will delegate its handling to the respective registered type converter (or throw an error if no converter for that type exists).
Serialization of Complex Data Structures
In the previous example it was quite simple to serialize ZonedDateTime into String. That’s not always the case. In some situations, you might need to serialize collections, complex nested data structures, etc. There is no one-size-fits-all approach to that, but I usually use Json serialization/deserialization library like Gson or Moshi to tackle this use case.
Imagine that our Room entity evolves into this:
@Entity(tableName = "appMemoryInfo") data class AppMemoryInfo( @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long, @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float, @field:ColumnInfo(name = "datetime") val zonedDateTime: ZonedDateTime, @field:ColumnInfo(name = "payload") val payload: MyComplexDataStructure, )
MyComplexDataStructure
is a custom entity that has a deep nested structure and, therefore, serializing it manually would be too time-consuming and error-prone. Type converter that uses Gson to the rescue:
class MyComplexDataStructureConverter { private val gson = Gson() @TypeConverter fun fromString(serialized: String): MyComplexDataStructure { return gson.fromJson(serialized, MyComplexDataStructure::class.java) } @TypeConverter fun toString(entity: MyComplexDataStructure): String { return gson.toJson(entity) } }
Register this type converter with MyRoomDatabase
and I’m done! Gson will handle all the heavy lifting for me.
Type Converters and Dependency Injection
In the above implementation of MyComplexDataStructureCoverter
I instantiate Gson right inside that class. That’s probably fine for this simple example, but, in real production applications, type converters can be more complex and require additional dependencies. I wouldn’t want to instantiate all of them inside type converters, especially if they have a deep tree of their own dependencies which I’ll need to instantiated as well. Furthermore, it’s a good practice to use a single instance of Gson in the app for performance reasons.
That’s where Dependency Injection architectural pattern comes in very handy. It allows us to delegate the instantiation of objects to an external entity called Composition Root. However, if I’ll just add Gson as a constructor argument of
like this:MyComplexDataStructureCoverter
class MyComplexDataStructureConverter(private val gson: Gson) { @TypeConverter fun fromString(serialized: String): MyComplexDataStructure { return gson.fromJson(serialized, MyComplexDataStructure::class.java) } @TypeConverter fun toString(entity: MyComplexDataStructure): String { return gson.toJson(entity) } }
Then I’ll see this build error:
Classes that are used as TypeConverters must have no-argument public constructors. Use a ProvidedTypeConverter annotation if you need to take control over creating an instance of a TypeConverter.
Room once again gives us a very clear explanation of the problem and suggests a solution that we can use. Let’s implement that solution.
First, I’ll annotate
with MyComplexDataStructureCoverter
@ProvidedTypeConverter
annotation:
@ProvidedTypeConverter class MyComplexDataStructureConverter(private val gson: Gson) { @TypeConverter fun fromString(serialized: String): MyComplexDataStructure { return gson.fromJson(serialized, MyComplexDataStructure::class.java) } @TypeConverter fun toString(entity: MyComplexDataStructure): String { return gson.toJson(entity) } }
If I rebuild the app now, it will succeed. However, I’ll get a runtime crash stating that a type converter for
e is missing. That’s because once I use the above annotation, Room won’t instantiate the respective type converter for me anymore. Instead, I’ll need to handle that myself.MyComplexDataStructur
So, let’s bind this type converter to MyRoomDatabase
manually (if you wonder what’s the role of MyDatabase
wrapper, you can read part 2 of this series):
class MyDatabase(context: Context, gson: Gson) { private val myRoomDatabase: MyRoomDatabase init { myRoomDatabase = Room.databaseBuilder( context, MyRoomDatabase::class.java, DatabaseConstants.DATABASE_NAME ).apply { addTypeConverter(MyComplexDataStructureCoverter(gson)) }.build() } val appMemoryInfoDao get() = myRoomDatabase.appMemoryInfoDao }
After this step, my application will both build and execute without problems.
Serialization vs Relationship with Another Table
Please note that when you serialize a complex data structure, like I did above, it’s a trade-off.
On the one hand, this approach is very simple.
On the other hand, you’ll pay this price for the simplicity:
- You won’t be able to use the contents of the serialized data structure in
WHERE
clauses of queries. - You won’t be able to retrieve just a subset of the information contained in
MyComplexDataStructure
. - You won’t be able to use migrations to evolve
MyComplexDataStructure
‘s schema.
Therefore, before you write type converters for complex data structures, pause for a moment and consider the long-term implications. In some cases, this trade-off makes sense. In other cases, you might need to store MyComplexDataStructure
in another database table and define a relation to it using foreign keys.
Conclusion
Type converters are yet another great Room’s feature. They enable you to work with custom data types in your database in a very simple and elegant way. I believe that most real-world Android applications that use Room will need to handle custom data types at some point, so now you know how to do that using Room’s type converters.
Thanks for reading and don’t forget to subscribe to my mailing list if you want to be notified about new posts.