Kotlin's type system is expressive enough to let you write code that is simultaneously statically typed, runtime validated, and ergonomic at the call site. That combination usually requires some machinery — and understanding why the machinery exists, rather than just how to copy it, is the difference between architecture and cargo-culting.
For reference, we will use the RandomPokemon repo.
The BaseViewModel in this codebase is a clean example of a pattern where self-referential generics, reified type parameters, and a sealed class hierarchy solve a real problem at a boundary where compile-time types naturally blur.
An implementation like this enforces a single, standardized way to consume use case output that makes type mismatches observable before they reach production.
The Problem at the UI/Domain Boundary
ViewModels sit at a boundary between two distinct type worlds. The domain layer produces ResultState — a sealed class that carries either a message, an error, or a payload typed as Any?:
sealed class ResultState {
data class Running(val message: String? = null) : ResultState()
data class Error(val error: Throwable? = null) : ResultState()
data class Complete<out O>(val data: O?) : ResultState()
}
The UseCase<in O> abstraction wraps this in a StateFlow<ResultState?>, where O is the expected output type:
abstract class UseCase<in O>(private val logger: Logger? = null) {
abstract val name: String
private val _state = MutableStateFlow<ResultState?>(null)
val state: StateFlow<ResultState?> = _state
protected fun running(message: String? = null) {
logger?.d("Running $name: $message")
_state.value = ResultState.Running(message = message)
}
protected fun Throwable?.update() {
logger?.e(this ?: Throwable(), "Error in $name")
_state.value = ResultState.Error(error = this)
}
protected fun O?.update() {
logger?.d("Completed $name: $this")
_state.value = ResultState.Complete<O>(data = this)
}
// ...
}
The variance declarations here are intentional and worth noting: Complete<out O> is covariant because ResultState produces values of type O (you read .data out of it), while UseCase<in O> is contravariant because a use case consumes O to update its state (via protected fun O?.update()) — it never hands O back to a caller. If you try to implement this pattern and swap the variance annotations, the compiler will tell you exactly where the flow of data breaks.
Notice that O is a contravariant input type parameter (used only in protected fun O?.update()), but the exposed StateFlow is typed to ResultState? — the O is erased at the boundary. By the time the ViewModel receives useCase.state, it has no compile-time knowledge of what type is inside ResultState.Complete.
This is inherent to the architecture. Use cases communicate via a shared ResultState channel, which must be typed to the sealed class rather than each use case's specific output type. You can't parameterize StateFlow over a generic that varies per use case if the ViewModel holds multiple use cases simultaneously.
So, where do you recover type safety, and at what cost?
The ViewModel's Generic Parameter
BaseViewModel is generic over its own state model:
open class BaseViewModel<M : ViewModelState>(initialState: M) : ViewModel(), KoinComponent {
private val _state: MutableStateFlow<M> = MutableStateFlow(initialState)
val state: StateFlow<M> = _state.asStateFlow()
protected fun updateState(transform: (M) -> M) {
_state.update(transform)
}
}
The M : ViewModelState bound means the ViewModel's own state (the UI state model) is typed. This is the self-referential part: HomeViewModel extends BaseViewModel<HomeState>, binding M to HomeState. The updateState lambda then gives subclasses a type-safe transformation — transform: (HomeState) -> HomeState — rather than an untyped mutation.
This is a well-understood pattern. The interesting part is how collectUseCase operates across both generic contexts — and what it enforces.
Two Generics, One Function
protected inline fun <reified O> UseCase<O>.collectUseCase(
crossinline onRunning: (String?) -> Unit = {},
crossinline onError: (Throwable?) -> Unit = { e -> onUseCaseError(e) },
crossinline onDone: suspend (O?) -> Unit = {}
) = viewModelScope.launch {
this@collectUseCase.state.collect {
it?.let {
it.parse<O>(
onError = { e -> onError(e) },
onRunning = { m -> onRunning(m) },
) { result -> onDone(result) }
}
}
}
This extension function on UseCase<O> is declared inside BaseViewModel<M>. It introduces its own generic parameter O (reified), independent of the class-level M. The function has access to viewModelScope (from the enclosing BaseViewModel context) while O is the expected output type of the specific use case being collected.
The call site looks like this:
getPokemon.collectUseCase<PokemonDetails>(
onRunning = { updateState { it.copy(loading = true) } },
onError = { e -> updateState { it.copy(error = e) } }
) { details -> updateState { it.copy(pokemon = details) } }
The compiler infers O = PokemonDetails from the reified type parameter. Inside onDone, result is PokemonDetails? — statically typed. No cast at the call site.
The type recovery happens inside ResultState.parse<O>:
inline fun <reified O> parse(
onRunning: (String?) -> Unit = {},
onError: (Throwable?) -> Unit = {},
onComplete: (O?) -> Unit = {},
) {
when (this) {
is Running -> onRunning(this.message)
is Error -> onError(this.error)
is Complete<*> -> this.data?.let {
it.takeIf { it is O }
?.let { result -> onComplete(result as O?) }
?: run { onError(IllegalArgumentException(
"Expected output of type ${O::class.simpleName}; got ${data?.let { data::class.simpleName }}"
)) }
} ?: run { onComplete(null) }
}
}
The Complete<*> star projection in the when branch is not stylistic — it's required by the JVM's type erasure. Writing is Complete<O> is illegal at runtime because the generic parameter is erased; the JVM only sees Complete, not Complete<PokemonDetails>. The star projection is an honest acknowledgment of that: "I know this is a Complete, but I can't assert what it contains yet." The payload type is recovered one step later, inside the branch, where takeIf { it is O } uses the reified type token to perform the actual check. This two-step dance — structural match via star projection, then type recovery via reification — is the idiomatic Kotlin solution to the erasure problem at sealed class boundaries.
If the data is the expected type, the cast is safe, and onComplete receives a typed value. If not, the mismatch is routed to onError as an IllegalArgumentException with a diagnostic message naming both the expected and actual type.
A Single Entry Point as a Standard
Before examining the type mechanics in detail, it's worth stepping back and recognizing what collectUseCase is doing at the team level: it is the only sanctioned way to consume a use case in this codebase.
That's not a coincidence, and it's not just ergonomics. Every developer writing a new ViewModel hits the same function, receives the same structure, and is guided towards the same three concerns: what happens while loading, what happens on failure, and what happens on success. There is no alternative path — no way to collect { state -> ... } raw ResultState directly in a ViewModel without bypassing the base class entirely or going through the ceremony of exposing the use case state for collection within a viewModelScope.launch block. This meta-contract is strictly a team-level decision, and is by no means religiously enforced. Rather, it is the teams' agreed-upon convention aimed at readability, convenience, and standardization across potential verticals.
This matters for code review and maintenance in a way that documentation never can. You can write a coding standard that says "always handle the running and error states." You can also write collectUseCase, which makes not handling them an active decision that requires extra effort. The default onError implementation is:
crossinline onError: (Throwable?) -> Unit = { e -> onUseCaseError(e) }
onUseCaseError logs the throwable. A developer who forgets to pass an onError handler doesn't silently swallow failures — the fallback ensures breakage is still observed, just in logcat rather than the UI. Omitting error handling is now a deliberate downgrade, not an oversight.
The explicit type annotation at the call site — collectUseCase<PokemonDetails> — is also load-bearing as communication. It's documentation that can't become stale: whoever reads this call knows exactly what the ViewModel expects this use case to produce, without navigating to the use case's definition. It turns a consumption pattern into a declaration of intent.
What "Reified as a Bridge" Means in Practice
Reified type parameters exist specifically to recover type information that is erased at runtime. Kotlin erases generic types — a List<String> and a List<Int> are the same List at runtime. Reification retains the type token, making O::class and is O valid at runtime in inline functions.
The pattern here uses reification to bridge two type worlds:
-
Compile-time world:
UseCase<PokemonDetails>— the use case is typed to its output. -
Runtime world:
StateFlow<ResultState?>— type information is erased at the shared channel level. -
Recovery point:
parse<O>()— reification reconstructs the type token at the collection site.
This bridge appears at the UI/domain boundary because that's where the erasure is intentional. The ResultState channel should be untyped at the ViewModel level — you want to collect multiple use cases with different output types through a single mechanism. Reification lets you express "I expect this to be of type O" at the point where you actually consume the value, without forcing every use case through a differently-typed channel.
The Path of Breakage
This is where the pattern earns most of its justification, and it's worth being precise about.
Consider what happens without parse<O>(). The raw alternative at the ViewModel collection site would look something like:
useCase.state.collect { state ->
when (state) {
is ResultState.Complete<*> -> {
val data = state.data as? PokemonDetails // as? cast
if (data != null) updateState { it.copy(pokemon = data) }
}
// ...
}
}
The as? cast is the failure mode. If state.data is actually a PokemonDto — because a mapping step was accidentally skipped somewhere in the call chain — the cast returns null silently. The UI receives a copy(pokemon = null). Nothing renders. There is no error. No log. No stack trace. A developer debugging this sees a blank screen and starts at square one.
The parse<O>() approach routes that same mismatch differently:
it.takeIf { it is O }
?.let { result -> onComplete(result as O?) }
?: run { onError(IllegalArgumentException(
"Expected output of type ${O::class.simpleName}; got ${data?.let { data::class.simpleName }}"
)) }
The same scenario — PokemonDto where PokemonDetails was expected — now produces an IllegalArgumentException with the message: "Expected output of type PokemonDetails; got PokemonDto." That exception is passed to onError, which routes it to the ViewModel's error state, which renders an error in the UI.
The difference in diagnosability is significant. In QA or during local testing, a visible error with a type name in the message points immediately to the mapping layer. The developer knows which use case is involved (they called getPokemon.collectUseCase<PokemonDetails>), what was expected (PokemonDetails), and what was actually received (PokemonDto). The breakage surface is named, localized, and actionable before the code ever reaches a release branch.
This is the pattern's core value proposition: type mismatches that would silently corrupt state instead generate observable, diagnosable failures during development. The system doesn't prevent the upstream bug — a wrong mapping is still a wrong mapping — but it ensures the downstream symptom is never a mystery. There's a clear and consistent path from breakage to identification.
It also means the pattern scales with the codebase. As more use cases are added, each one follows the same contract. A refactor that changes GetPokemon to return PokemonSummary instead of PokemonDetails doesn't silently break the ViewModel — it loudly breaks at runtime the first time the use case is collected, with a message that names exactly what changed. The type annotation at the call site becomes a lightweight contract test.
The Residual Cost
Naming these benefits doesn't erase the genuine trade-offs.
The type check remains runtime, not compile-time. The compiler cannot catch a mismatch between UseCase<PokemonDto> emitting into collectUseCase<PokemonDetails>. The type guard catches it, but during testing, not during compilation. If your test coverage of the happy-path data flow is thin, the mismatch can reach a release build.
The inline declaration has a direct impact on unit testing. Because collectUseCase is inline, MockK and Mockito cannot mock or spy on it at the function level — you can't assert "was collectUseCase called with the right type." In practice, this turns out to be a non-issue, and arguably a nudge in the right direction: you test the ViewModel by emitting ResultState values from a fake UseCase.state flow and asserting on the resulting ViewModel.state. Since state is a plain StateFlow<ResultState?>, wiring a test double is straightforward — every { useCase.state } returns MutableStateFlow(ResultState.Complete(pokemonDetails)) — and testing the output rather than the wiring is a more meaningful assertion anyway. The one place this becomes relevant is verifying error routing: emitting a ResultState.Complete containing the wrong type (e.g., PokemonDto instead of PokemonDetails) in a test will confirm that onError is invoked with the IllegalArgumentException, giving you a concrete test for the contract the pattern promises.
Inline functions with crossinline lambdas have constraints. The crossinline onDone parameter cannot use non-local returns or call suspend functions directly without explicit suspend on the lambda type. The signature accounts for this — onDone: suspend (O?) -> Unit — but it's a constraint the caller must be aware of.
Three simultaneous generic contexts add cognitive load. A HomeViewModel calling getPokemon.collectUseCase<PokemonDetails> is in BaseViewModel<HomeState> (class generic), calling an extension on UseCase<PokemonDetails> (receiver generic), with its own reified O = PokemonDetails (function generic). That's a lot to hold at once for someone unfamiliar with the pattern.
These costs are real. They're also bounded: the cognitive overhead is paid once when learning the pattern, the compile-time limitation is mitigated by the runtime guard, and the crossinline constraints are surfaced immediately by the compiler when you try to violate them.
When This Pattern Earns Its Complexity
The pattern is justified when all three of the following are true:
- You have a shared communication channel that must erase type information (a
StateFlow<SealedBase?>). - Consumers need typed access to the payload on the other side (arguably the most important constraint).
- Silent type failures are unacceptable — mismatches should be observable and diagnosable (Also, just good practice).
If your use cases emit a different sealed class per output type, you don't need this — the types are preserved all the way to the consumer. If as?-with-null is an acceptable failure mode, you don't need the runtime guard. If the team is small enough that shared conventions can be maintained by proximity, the enforced entry point is just overhead.
But if you're building a ViewModel layer that will be extended by multiple developers across a growing feature set — where the distance between "use case author" and "ViewModel author" creates risk — the combination of a single enforced collection mechanism + explicit type declaration + diagnostic failure routing is a structural answer to a coordination problem. It turns what could be a verbal convention ("always annotate the expected type," "always handle errors") into something the codebase enforces on its own.
Type safety through a type-erased channel isn't a contradiction. It's a deliberate choice about where to bear the cost of erasure, made in favor of visibility over silence.

Top comments (0)