A kotlin re-imagining of Typed! for Android

Maven Central

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)