typedmap
is an implementation of heterogeneous type-safe map pattern in Kotlin. It is a data structure similar to a regular map, but with two somewhat contradicting features:
-
Heterogeneous - it can store items of completely different types (so this is like
Map<Any?, Any?>
). -
Type-safe - we can access the data in a type-safe manner and without manual casting (unlike
Map<Any?, Any?>
).
To accomplish this, instead of parameterizing the map as usual, we need to parameterize the key. Keys are used both for identifying items in the map and to provide us with the information about the type of their associated values.
As this is much easier to explain and understand by looking at examples, we will go straight to the code!
Probably the most common example of a similar data structure is an API where we provide a Class
to get an instance of it. In Java, it could look like this:
public <T> T get(Class<T> cls)
Guava’s ClassToInstanceMap is a good example of such a data structure. Additionally, there are e.g. EntityManager.unwrap() and BeanManager.getExtension() methods that utilize similar API, however, their purpose is more specialized.
typedmap
supports this feature with a clean API:
// create a typed map
val sess = simpleTypedMap()
// add a User item
sess += User("alice")
// get an item of the User type
val user = sess.get<User>()
println("User: $user")
// User(username=alice)
Due to advanced type inferring in Kotlin, in many cases we don’t need to specify a type when getting an item:
fun processUser(user: User) { ... }
processUser(sess.get()) // Works as expected
fun getUser(): User {
return sess.get() // Works as expected
}
typedmap
fully supports parameterized types:
sess += listOf(1, 2, 3, 4, 5)
sess += listOf("a", "b", "c", "d", "e")
println("List<Int>: ${sess.get<List<Int>>()}")
// [1, 2, 3, 4, 5]
println("List<String>: ${sess.get<List<String>>()}")
// [a, b, c, d, e]
Note
|
SimpleTypedMap , which we use here, does not support polymorphism. Both get<Collection<Int>>() and get<List<Number>>() would not find a requested item and throw an exception. Polymorphism could be supported by more advanced implementations of TypedMap .
|
Looking for items by their type is very convenient, but in many cases this is not enough. For example, it is difficult to store multiple instances of the same class and access them individually. Moreover, if we need to store an item of a common type, e.g. String
, then the code get<String>()
becomes enigmatic, because it is not clear what is the String
we requested. In such cases, we can create keys to identify items in the map.
Let’s assume we develop a web application, and we store some data in the web session. In the previous example, we stored a user object in the session, but this time we just need to store a username. In addition, we would like to store a session ID and visits count. We have to create a key for each item and use these keys to identify values:
object Username : TypedKey<String>()
object SessionId : TypedKey<String>()
object VisitsCount : TypedKey<Int>()
sess[Username] = "alice"
sess[SessionId] = "0123456789abcdef"
sess[VisitsCount] = 42
println("Username: ${sess[Username]}")
// "alice"
println("SessionId: ${sess[SessionId]}")
// "0123456789abcdef"
println("VisitsCount: ${sess[VisitsCount]}")
// 42
When creating a key, we need to provide a type of its associated value. This makes possible to provide a fully type-safe API:
val username = sess[Username] // type: String
val visits = sess[VisitsCount] // type: Int
sess[Username] = 50 // compile error
Similarly as in the previous section, we can use keys in conjunction with parameterized types:
object UserIds : TypedKey<List<Int>>()
object Labels : TypedKey<List<String>>()
sess[UserIds] = listOf(1, 2, 3, 4, 5)
sess[Labels] = listOf("a", "b", "c", "d", "e")
sess[Labels] = listOf(1, 2, 3, 4, 5) // compile error
println("UserIds: ${sess[UserIds]}")
// [1, 2, 3, 4, 5]
println("Labels: ${sess[Labels]}")
// [a, b, c, d, e]
Declaring keys in the way described above is fine if we need to store a finite set of known items, so we can create a distinct key for each of them. In practice though, we very often need to create keys dynamically and store an arbitrary number of items in a map. This is supported by typedmap
as well, and we still keep its type-safety feature. In fact, this case is implemented in typedmap
in a very similar way to regular maps.
Instead of creating the key as a singleton object, we need to define it as a class. hashCode()
and equals()
have to be properly implemented, so the easiest is to use a data class
:
// value
data class Order(
val orderId: Int,
val items: List<String>
)
// key
data class OrderKey(
val orderId: Int
) : TypedKey<Order>()
sess[OrderKey(1)] = Order(1, listOf("item1", "item2"))
sess[OrderKey(2)] = Order(2, listOf("item3", "item4"))
println("OrderKey(1): ${sess[OrderKey(1)]}")
// Order(orderId=1, items=[item1, item2])
println("OrderKey(2): ${sess[OrderKey(2)]}")
// Order(orderId=2, items=[item3, item4])
This example could be improved by using the AutoKey
util. AutoKey
is a very simple interface that we can implement to make map items responsible for creating their keys:
data class Order(
val orderId: Int,
val items: List<String>
) : AutoKey<Order> {
override val typedKey get() = OrderKey(orderId)
}
sess += Order(1, listOf("item1", "item2"))
sess += Order(2, listOf("item3", "item4"))
Note
|
You could notice that we used plusAssign() operator (+= ) earlier, and it had a different meaning. This is true, sess += Order() could be interpreted both as "set by autokey" (so the key is OrderKey object) or as "set by type" (key is similar to Class<Order> ). By default, objects implementing AutoKey are stored by autokey, which is probably what we really need. To store autokey objects by their type, we need to use setByType() function explicitly.
|
Add a following dependency to the gradle/maven file:
dependencies {
implementation "me.broot.typedmap:typedmap-core:${version}"
}
dependencies {
implementation("me.broot.typedmap:typedmap-core:${version}")
}
<dependency>
<groupId>me.broot.typedmap</groupId>
<artifactId>typedmap-core</artifactId>
<version>${version}</version>
</dependency>
Now, we can start using typedmap
:
val map = simpleTypedMap()
To build the project from sources, run the following command:
$ ./gradlew build
gradlew.bat build
After a successful build, the resulting jar file will be placed in:
-
typedmap-core/build/libs/typedmap-core.jar
Some people may ask: what do we need this for? Or even more specifically: how is the typed map better than just a regular class with known and fully typed properties? Well, in most cases it is not. However, there are cases where such a data structure could be very useful.
Sometimes, we need to separate the code responsible for providing a data storage and the code storing its data there. In such a case, the first component knows nothing about the data it stores, so the data container can’t be typed easily. Often, it is represented as Map<Any, Any>
, Map<String, Any>
or just Any/Object
.
Examples:
-
Session data in web frameworks - framework provides the storage, web application uses it.
-
Request/response objects in web/network frameworks - they often contain untyped data storage, so middleware or application developer could attach additional data to request/response.
-
Applications with support for plugins - plugins often need to store their data somewhere and application provides a place for it.
-
Data storage shared between loosely coupled modules.
Let’s assume we develop some kind of data processing software. Our data processing is very complex, so we divided the whole process into several smaller tasks and organized the code into clean architecture of multiple packages or even separate libraries. Modules produce results of their processing and may consume results of other modules, so we need a central cache for storing these results.
The problem is: central cache needs to know data structures of all available modules, so we partially lose benefits of our clean design. It is even worse if modules are provided as external libraries.
-
Objects designed to be externally extensible, i.e. by other means than subtyping. We can easily add new behavior to a class by extension or static functions, but we can’t add any additional data fields to it. Similar example are classes allowing to attach hooks to affect their behavior.
-
Separation of concerns. Often, we divide the code of our application into a utility of generic usage and a code related to an application logic. In such a case we don’t want to pollute utility classes with an application logic, but sometimes we still need to somehow reference application objects from utility classes. Usually, it can be solved with generics though.
Above cases aren’t very common, some of them are rather rare. Still, it happens from time to time. Generally speaking, whenever we design or use a class which owns a property like Any
or Map<String, Any>
with contract like: "Put there any data you need, it won’t be modified, but just kept for you", the typed map structure could be potentially useful. Such properties are often named "extras", "extra data", "properties", etc.
There are several existing examples in Java with similar requirements to described above and implemented using either untyped container and manual casting or with a class-to-instance map or function:
-
In Servlet API we could store and retrieve additional untyped data at request level (ServletRequest.getAttribute()), session level (HttpSession.getAttribute()) and globally (ServletContext.getAttribute()).
-
Jax-RS Filters (middleware) could store/retrieve additional request data using: ContainerRequestContext.getProperty().
-
In JPA we could store custom data in EntityManager in its properties: EntityManager.getProperties(), although I’m not sure this functionality was intended for such purposes.
-
Spring Framework uses untyped map for session data: WebSession.getAttribute(), Session.getAttribute(). It is a little smarter than previous examples, because it uses type inference, so we don’t need to cast the value manually. It doesn’t change much though - we still need to remember which value was stored with which key and specify the type manually.
-
In Spring Integration one of its main components, Message, is a generic data holder/wrapper and it could contain headers which is basically an untyped map. Additionally, there are wrappers for this untyped map to provide a strongly typed API, e.g.: SimpMessageHeaderAccessor.
-
In Android, we very often pass data as untyped Bundle map, for example in Intent extras.
Additionally, there are examples of data structures very similar to typedmap
. In fact, they exist in one of the most popular libraries for Kotlin:
-
CoroutineContext - its
Key
interface andget()
function. It allows to store any data within a coroutine. -
Attributes of Ktor web framework by JetBrains. It is used as a storage for middleware.
Typed maps aren’t the only solution to a similar problem. There are other techniques, including:
-
Use untyped map (e.g.
Map<Any?, Any?>
) as a central storage and provide strongly-typed accessors by clients/modules. Accessors could be: extension functions, static functions or even classes that wrap untyped map and provide an easy to use API.This solution could be very convenient to use, especially with extension functions, however, writing accessors requires much more work than just creating a typed key. Furthermore,
typedmap
naturally guarantees that each key is unique. Accessors need to do the same or they would risk conflicts. -
class-to-instance maps.
In many cases they are less convenient to use. For example, if we need to store multiple simple items (strings, integers), we need to create a wrapper class for each of them and then wrap/unwrap a value whenever storing/retrieving it. Also, it is not trivial to store collections of items as in Key With Data.