A kotlin re-imagining of Typed! for Android
The premise:
“Fix” Android’s obnoxious Key-Value stores by defining Keys that have all info necessary to get/set values including a return Type, a defaultValue (if any) and any serialization/deserialization instructions.
Setup
def typed2Version = "2.0.0-alpha04"
dependencies {
// core implementation: supports SharedPreferences, Intents, Bundles & PersistableBundles
implementation "com.episode6.typed2:core:$typed2Version"
// optional add-on modules
implementation "com.episode6.typed2:datastore-preferences:$typed2Version"
implementation "com.episode6.typed2:saved-state-handle:$typed2Version"
implementation "com.episode6.typed2:navigation-compose:$typed2Version"
// optional serialization support
implementation "com.episode6.typed2:gson:$typed2Version"
implementation "com.episode6.typed2:kotlinx-serialization-json:$typed2Version"
implementation "com.episode6.typed2:kotlinx-serialization-bundlizer:$typed2Version"
}
Typed2 v2.0.0-alpha04 is compiled against Kotlin v2.3.21 and Coroutines v1.9.0
Usage
With Typed2, we declare our keys in an object that subclasses a KeyNamespace. Each key namespace is specific to the type of object that key can be used with.
SharedPreferences Example…
object PrefKeys : PrefKeyNamespace(prefix = "com.sample.prefkey.") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string() // no default means null is the default
}
val sharedPreferences: SharedPreference = TODO()
fun main() {
// types & nullability are enforced by the keys
val someInt = sharedPreferences.get(PrefKeys.MY_INT)
val someString = sharedPreferences.get(PrefKeys.MY_STRING)
sharedPreferences.edit {
set(PrefKeys.MY_INT, 42)
set(PrefKeys.MY_STRING, "answer")
}
}
The datastore-preferences module adds support for Jetpack DataStore (Preferences). Because DataStore has no synchronous access,
DataStoreKeys are always async — get() and set() are suspend functions and no async() call is needed on primitive keys…
object DataKeys : DataStoreKeyNamespace(prefix = "com.sample.datakey.") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string()
// serialization keys work too, their (potentially expensive) mapping is dispatched using async()
val MY_GSON_OBJ = key("gsonObj").gson<SomeDataClass>().async()
}
val dataStore: DataStore<Preferences> = TODO()
suspend fun main() {
// types & nullability are enforced by the keys
val someInt: Int = dataStore.get(DataKeys.MY_INT)
val someString: String? = dataStore.get(DataKeys.MY_STRING)
dataStore.edit {
set(DataKeys.MY_INT, 42)
set(DataKeys.MY_STRING, "answer")
}
// every key can also be observed as a Flow
val intFlow: Flow<Int> = dataStore.flow(DataKeys.MY_INT)
}
Also works with Bundles…
object Arguments : BundleKeyNamespace(prefix = "com.sample.arguments.") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string()
}
val bundle: Bundle = TODO()
val intent: Intent = TODO()
fun main() {
// types are enforced by the keys
val someInt: Int = bundle.get(Arguments.MY_INT)
val someString: String? = intent.getExtra(Arguments.MY_STRING)
bundle.set(Arguments.MY_INT, 23)
intent.setExtra(Arguments.MY_STRING, "mj4l")
}
Can also be used to define screens for Navigation-Compose, enabling type-safe navigation arguments.
object MyScreen : NavScreen(name = "myScreen") {
val MY_INT = key("someInt").int(default = 2)
val MY_STRING = key("someString").string()
}
val savedStateHandle: SavedStateHandle = TODO()
val navController: NavController = TODO()
@Composable fun MyNavigationDefinition(navController: NavHostController) {
NavHost(navController = navController, startScreen = MyScreen) { // note: startScreen must not have any required args
// automatically define the route based on MyScreen's arguments
composableScreen(MyScreen) {
/* actual composable UI */
}
}
}
fun main() {
// can pull nav arguments from either SavedStateHandles or Bundles
val someInt: Int = savedStateHandle.get(Arguments.MY_INT)
val someString: String? = savedStateHandle.get(Arguments.MY_STRING)
// type-safe navigation arguments
navController.navigateTo(MyScreen) {
set(MyScreen.MY_INT, 5)
set(MyScreen.MY_STRING, "hi")
}
}
Object Serialization
We supply 3 modules to handle object serialization (they’re very simple and it should be easy to build your own as well).
object PrefKeys : PrefKeyNamespace() {
// with gson we can convert any data class to/from json using reflection
val MY_GSON_OBJ = key("gsonObj").gson<SomeDataClass>()
// with kotlinx-serialization-json we can convert classes annotated with @Serializable to/from json
val MY_JSON_OBJ = key("jsonObj").json(default = SerialDataClass(), SerialDataClass::serializer)
}
// with kotlinx-serialization-bundlizer we can convert classes annotated with @Serializable to/from a Bundle (only applies to BundleKeyNamespace)
object Arguments : BundleKeyNamespace() {
val MY_VIEW_STATE = key("viewState").bundlized(ViewState::serializer)
}
// the string-backed serializers also work in a DataStoreKeyNamespace; append async() to make them
// compatible with the (always async) DataStore APIs and dispatch their mapping off the main thread
object DataKeys : DataStoreKeyNamespace() {
val MY_GSON_OBJ = key("gsonObj").gson<SomeDataClass>().async()
}
Async Support
Typed2 is built with kotlin coroutines in mind. Any key can force its mapping onto a background thread using the async() function. When
using AsyncKeys, the get() and set() functions will be suspend functions.
object PrefKeys : PrefKeyNamespace() {
// async() forces the gson execution into a coroutine run on Dispatchers.Default (by default)
val MY_ASYNC_OBJ = key("asyncObj").gson<SomeBigDataClass>().async()
}
val sharedPreferences: SharedPreference = TODO()
fun main() {
coroutineContext {
val obj = sharedPreferences.get(MY_ASYNC_OBJ)
}
}
DataStore keys are inherently async — the primitive builders in a DataStoreKeyNamespace return AsyncKeys without any async() call
(and without paying for a dispatcher hop), so get() and set() are always suspend functions. Only serialization/mapped keys need an
explicit async(), which chooses the dispatcher for their (potentially expensive) mapping.
Observing Keys as Flows
The observable key-value stores (SharedPreferences, DataStore & SavedStateHandle) include flow() extension methods to observe
individual keys. These flows emit the key’s current value on collection, then emit again whenever the underlying value changes.
val stringFlow: Flow<String?> = sharedPreferences.flow(PrefKeys.MY_STRING)
val dataIntFlow: Flow<Int> = dataStore.flow(DataKeys.MY_INT)
val savedStateFlow: Flow<String?> = savedStateHandle.flow(MyScreen.MY_STRING)
Properties and MutableStateFlows
All supported key-value stores also include extension methods to generate property delegates and MutableStateFlows
// getting and setting this var will call SharedPreferences.get() and set() under the hood
var intPref: Int by sharedPreferences.property(PrefKeys.MY_INT)
// when using async key, properties will always be nullable and will always start as null
var jsonObj: SerialDataClass? by sharedPreferences.property(PrefKeys.MY_JSON_OBJ, viewModelScope)
// create a mutableStateFlow that writes new values back to sharedPreferences
val stringMutableStateFlow: MutableStateFlow<String> = sharedPreferences.mutableStateFlow(PrefKeys.MY_STRING, viewModelScope)
// when using async keys, mutableStateFlows will always be nullable and use null as an initial value
val jsonObjMutableStateFLow: MutableStateFlow<SerialDataClass?> = sharedPreferences.mutableStateFlow(PrefKeys.MY_JSON_OBJ, viewModelScope)
Since DataStore keys are always async, DataStore mutableStateFlows can’t start with a real value. Their emissions are wrapped in a
DataStoreValue so consumers can distinguish the uninitialized state from a genuine read of an absent key. DataStore properties are
always nullable, start as null, and setting them to null removes the entry.
// starts as DataStoreValue.Uninitialized, then emits DataStoreValue.Loaded(value) once read
// (a Loaded(null) emission means the key is not present in the store)
val dataIntMutableStateFlow: MutableStateFlow<DataStoreValue<Int>> = dataStore.mutableStateFlow(DataKeys.MY_INT, viewModelScope)
// set DataStoreValue.Loaded values to write back to the store; use .valueOrNull to unwrap emissions
dataIntMutableStateFlow.value = DataStoreValue.Loaded(42)
val currentInt: Int? = dataIntMutableStateFlow.value.valueOrNull
var dataInt: Int? by dataStore.property(DataKeys.MY_INT, viewModelScope)