Room - 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 Instant to a Long for a SQLite integer column.
    • Map a Location to a String for a SQLite text column.
    • Map a collection of String to a single String (e.g., CSV) for a text column.
    Nota

    Type converters are strictly 1:1 (one property ↔ one column).

    Setting Up a Type Converter

    1. Create any Kotlin class to hold converter functions.
    2. For each type pair, create two functions: A→B and B→A. If input is null, return null. Ensure the round-trip preserves values.
    3. Annotate both functions with @TypeConverter.
    4. Bring them into scope with @TypeConverters on one of:
    @TypeConverters on…Applies to…
    RoomDatabaseeverything in the database
    Entity classall properties in that entity
    Entity propertythat property only
    DAO classall DAO functions
    DAO functionthat function (all params)
    DAO function parameterthat 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: Location

    Resulting 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

    Persisting a Money value

    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 @Embedded with a prefix.

    Requirements

    • Implement Order and OrderItem entities that use Money.
    • Provide minimal SQL methods (insert + select) to verify round-trips for both approaches.
    • Ensure stable formatting and locales.
    • Prefer BigDecimal for amount; if using Double, 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).