Custom Type
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:
(
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:
(
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).
Show solution