Persist non-primitive types in Room using TypeConverters or Embedded types.
Type Converter
Type converters are pairs of functions, annotated with @TypeConverter, that translate a single database column type to a Kotlin property type and back.
Typical uses:
- Map an
Instantto aLongfor a SQLiteintegercolumn. - Map a
Locationto aStringfor a SQLitetextcolumn. - Map a collection of
Stringto a singleString(e.g., CSV) for atextcolumn.
Type converters are strictly 1:1 (one property ↔ one column).
Setting Up a Type Converter
- Create any Kotlin class to hold converter functions.
- For each type pair, create two functions: A→B and B→A. If input is
null, returnnull. Ensure the round-trip preserves values. - Annotate both functions with
@TypeConverter. - Bring them into scope with
@TypeConverterson one of:
| @TypeConverters on… | Applies to… |
|---|---|
| RoomDatabase | everything in the database |
| Entity class | all properties in that entity |
| Entity property | that property only |
| DAO class | all DAO functions |
| DAO function | that function (all params) |
| DAO function parameter | that single parameter |
Example: an entity using converters for Instant, Location, and Set<String>:
@Entity(tableName = "record")@TypeConverters(InstantTypeConverter::class, LocationTypeConverter::class, SetTypeConverter::class)data class Record( @PrimaryKey(autoGenerate = true) val id: Long = 0, val instant: Instant = Clock.System.now(), val location: Location, val tags: Set<String> = setOf()) { @Dao interface SQL { @Query("select * from record") suspend fun select(): List<Record>
@Insert suspend fun insert(entity: Record) }}
data class Location(val latitude: Double, val longitude: Double) { constructor(location: Location) : this(location.latitude, location.longitude)}InstantTypeConverter
SQLite does not have a native date/time type. The recommended approach is to store timestamps as the number of milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC).
Instant has a toEpochMilliseconds() function that returns this value.
class InstantTypeConverter { @TypeConverter fun instantToLong(timestamp: Instant?) = timestamp?.toEpochMilliseconds()
@TypeConverter fun longToInstant(timestamp: Long?) = timestamp?.let { Instant.fromEpochMilliseconds(it) }}With @TypeConverters(InstantTypeConverter::class) in scope, Room will persist Instant as a SQLite integer.
LocationTypeConverter
Persist a Location (latitude/longitude) as a single String in a text column (e.g., “latitude;longitude”):
A Location object contains a latitude, longitude, and perhaps other values (e.g., altitude). If we only care about the latitude and longitude, we could save those in the database in a single text column, so long as we can determine a good format to use for that string. One possibility is to have the two values separated by a semicolon.
The LocationTypeConverter class has a pair of functions designed to convert between a Location and a String:
class LocationTypeConverter {
@TypeConverter fun locationToString(location: Location?) = location?.let { "${it.latitude};${it.longitude}" }
@TypeConverter fun stringToLocation(location: String?) = location?.let { val pieces = location.split(';') if (pieces.size == 2) { try { Location(pieces[0].toDouble(), pieces[1].toDouble()) } catch (e: Exception) { null } } else { null }
}}Trade-off: compact storage but poor queryability by location.
SetTypeConverter
When you don’t want a separate relation, encode collections. CSV is simple but limited; JSON is flexible:
class SetTypeConverter { @TypeConverter fun setToString(set: Set<String>) = Json.encodeToString(set)
@TypeConverter fun stringToSet(string: String) = Json.decodeFromString<Set<String>>(string)}Given these type conversion functions, we can use a Set of String values in Record:
val tags: Set<String> = setOf()…where the tags will be stored in a text column.
Embedded Types
Type converters map one property to one column.
When a single property should span multiple columns, use @Embedded on a simple Kotlin class and reference it in your entity.
Example: Locations
To query by latitude/longitude, embed them as separate columns:
@Entity(tableName = "embedded")data class EmbeddedRecord( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, @Embedded val location: Location) { @Dao interface SQL { @Query("select * from embedded") suspend fun select(): List<EmbeddedRecord>
@Insert suspend fun insert(entity: EmbeddedRecord) }}Schema:
create table if not exists embedded ( id integer primary key autoincrement not null, name text not null, latitude real not null, longitude real not null)You may use @ColumnInfo inside embedded classes to rename columns.
Types within embedded classes must still be Room-supported (natively or via converters).
Simple vs. Prefixed
If you embed the same class multiple times, use a column prefix to avoid name collisions:
@Embedded(prefix = "office_")val officeLocation: LocationResulting columns:
create table if not exists embedded ( id integer primary key autoincrement not null, name text not null, office_latitude real not null, office_longitude real not null)Remember to use the prefixed names in any @Query.
Task
Create a Money value object with amount and currency and persist it in Room in two ways:
- As a single column using a
TypeConverter(e.g., JSON or “amount;currency”). - As embedded columns using
@Embeddedwith a prefix.
Requirements
- Implement
OrderandOrderItementities that useMoney. - Provide minimal SQL methods (insert + select) to verify round-trips for both approaches.
- Ensure stable formatting and locales.
- Prefer
BigDecimalfor amount; if usingDouble, document precision trade-offs. - Write tests that insert and read back values, asserting equality for:
- Multiple currencies (e.g., USD, EUR),
- Edge amounts (0, negative, large, fractional),
- Embedded with and without prefixes (e.g., price vs. discount).