For codebases with many different entities, most of us will at some point have hit a bug due to the wrong ID being assigned to a property or passed to a function. Why don't we use the type system to give all of our IDs their own type and ensure this never happens again.
It is very common for objects to need a form of identifier or ID, frequently the reason being to refer to entities when communicating with a remote API or a database. We can easily implement this by giving our classes an ID property with an appropriate type, such as String
, UUID
or Long
. An issue here is that imagine User
and Team
both have a String
ID, it would be perfectly possible to pass a User
ID to a function when we were meant to provide a Team
ID instead.
Let's explore the idea of enforcing type safety by giving our IDs their own types.
General types
One option for our IDs is to use the String
type, especially if they are in an unexpected format. Another common type of ID is a UUID, an implementation of which is provided by the Java standard library. UUID
is designed for storing IDs and is a 128-bit value, which can be easily converted to and from a String.
data class Team(val id: UUID, val size: Int)
data class TeamMember(val id: String, val name: String)
Tiny type
Rather than relying on the general types, we can easily create a dedicated one.
data class Identifier(val rawValue: UUID)
If we require different raw types, we can either create extra ID types that contain different raw types or use a single one with a generic type parameter. Using a generic version allows us to have properties or function arguments that require a particular type of ID.
data class Identifier<RawT>(val rawValue: RawT)
We can add helpful extensions to Identifier
for use with common raw types, such as pulling out the raw String
value:
val Identifier<UUID>.uuidString: String
get() = rawValue.toString()
Ensuring the correct type
There are some issues that can appear when using these general types internally. Say Team
has a UUID
ID and Member
has a String
ID:
- Every
Team
ID is aUUID
, but not everyUUID
refers to a validTeam
. - A
Member
ID could have the same value as the rawString
backing aTeam
ID, even though they refer to different entities.
Mixing and matching two different IDs that use the same raw type is perfectly valid and not prevented by the compiler. We can do better than this!
By adding a second generic type parameter to our Identifier
we can limit its use for a particular entity.
data class Identifier<EntityT, RawT>(
val rawValue: RawT
)
data class Room(val id: Identifier<Room, UUID>)
data class Meeting(val id: Identifier<Meeting, UUID>)
fun bookMeeting(id: Identifier<Meeting, UUID>) {}
// ❌ Compile error: Type mismatch.
bookMeeting(room.id)
Due to EntityT
not actually being used within Identifier
we will likely get a warning about it being unused, which can be ignored using @Suppress("unused")
.
Type aliases
When our codebase has a variety of entities, the number of IDs will grow and we are likely to get fed up with typing out the Identifier
signature. We can rely on type aliases to simplify this task. A nice organisation tip is to store these alongside the entity they identify, making them easy to find.
// Team.kt
typealias TeamId = Identifier<Team, UUID>
data class Team(val id: TeamId)
Specifying IDs in properties and functions is now so much simpler.
fun Team.inviteMember(id: MemberId) {}
// ❌ Compile error: Type mismatch.
team.inviteMember(team.id)
// ✅ Compiles.
team.inviteMember(member.id)
Alternatives
If we wanted to avoid the generic type arguments, we could rely on the brevity of Kotlin data classes and simply have a separate tiny type for each ID.
data class MessageId(val raw: UUID)
data class ChatId(val raw: String)
data class PersonId(val raw: Long)
The advantage is they are significantly simpler due to being just basic data classes, they could even be inline classes. A possible downside is that with Identifier
we can write code that automatically converts Identifier
to other types for tasks such as storing in a Bundle
to pass to an Activity
or to a format Room
can store in a SQLite database. We will have to weigh up the pros and cons of each approach and decide what is best for our particular situation.
Wrap up
By using a type that enforces type safety of our entity IDs, we can make our model code safer to work on and avoid bugs due to an incorrect ID being used. Our code will also be more readable as when we see an ID we will know which entity it refers to. There may even be ways to extend this solution to make it even more powerful!
I hope the article was useful. If you have any feedback or questions please feel free to reach out.
Thanks for reading!
WRITTEN BY
Andrew Lord
A software developer and tech leader from the UK. Writing articles that focus on all aspects of Android and iOS development using Kotlin and Swift.
Want to read more?
Here are some other articles you may enjoy.