Skip to main content

Advanced Kotlin Patterns

Kotlin is a simple language on the surface and a deep one underneath. These patterns show up in every serious Android codebase — Jetpack source, Coil, OkHttp, Ktor — and you'll write them yourself once you hit real-world design problems.

Generics and variance

The out (covariant) and in (contravariant) modifiers

// A producer of T is covariant — you can use a Producer<String>
// where Producer<Any> is expected.
interface Producer<out T> {
fun produce(): T
// fun consume(t: T) // ERROR: T is out, can't appear in "in" positions
}

// A consumer of T is contravariant — you can use Consumer<Any>
// where Consumer<String> is expected.
interface Consumer<in T> {
fun consume(t: T)
// fun produce(): T // ERROR: T is in, can't appear in "out" positions
}

val anyProducer: Producer<Any> = object : Producer<String> {
override fun produce() = "hello"
}

val stringConsumer: Consumer<String> = object : Consumer<Any> {
override fun consume(t: Any) = println(t)
}

Mnemonic: "Producer Extends, Consumer Super" (PECS from Java). In Kotlin it's "Producer out, Consumer in." List<out T> is read-only; MutableList<T> is invariant because it produces AND consumes.

Use-site variance

When you can't change a declaration, annotate at the call site:

fun copy(source: MutableList<out Number>, dest: MutableList<in Number>) {
for (item in source) dest.add(item)
}

val ints: MutableList<Int> = mutableListOf(1, 2, 3)
val anys: MutableList<Any> = mutableListOf()
copy(ints, anys) // works: Int is a Number, Any is a supertype of Number

Reified type parameters

Normal generics are erased at runtime. reified keeps the type available — but only in inline functions:

inline fun <reified T> Gson.fromJson(json: String): T =
fromJson(json, T::class.java)

val user: User = gson.fromJson(payload) // T is User at runtime

// Classic Android use: starting an activity
inline fun <reified T : Activity> Context.start(init: Intent.() -> Unit = {}) {
val intent = Intent(this, T::class.java).apply(init)
startActivity(intent)
}

context.start<ProfileActivity> {
putExtra("userId", "u1")
}

Sealed classes and interfaces

Sealed types are closed hierarchies: the compiler knows every subtype, so when expressions are exhaustive without an else branch.

sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val throwable: Throwable) : UiState<Nothing>
}

@Composable
fun <T> render(state: UiState<T>, onRetry: () -> Unit, content: @Composable (T) -> Unit) {
when (state) { // exhaustive — no else needed
is UiState.Loading -> LoadingView()
is UiState.Success -> content(state.data)
is UiState.Error -> ErrorView(state.throwable, onRetry)
}
}

The Result pattern

sealed interface Outcome<out T, out E> {
data class Ok<T>(val value: T) : Outcome<T, Nothing>
data class Err<E>(val error: E) : Outcome<Nothing, E>
}

inline fun <T, E, R> Outcome<T, E>.fold(
onOk: (T) -> R,
onErr: (E) -> R
): R = when (this) {
is Outcome.Ok -> onOk(value)
is Outcome.Err -> onErr(error)
}

sealed interface LoginError {
data object WrongPassword : LoginError
data object NetworkDown : LoginError
data class Rate(val retryAfter: Duration) : LoginError
}

suspend fun login(email: String, pw: String): Outcome<Session, LoginError> {
return try {
Outcome.Ok(api.login(email, pw))
} catch (e: HttpException) when (e.code()) {
401 -> Outcome.Err(LoginError.WrongPassword)
429 -> Outcome.Err(LoginError.Rate(e.retryAfter()))
else -> Outcome.Err(LoginError.NetworkDown)
}
}

This beats Result<T> when you need typed errors the compiler enforces at call sites.

DSLs with receivers

Kotlin's "function with receiver" (A.() -> Unit) is how Compose, Gradle, Ktor, and Jetpack routing all look declarative.

class NotificationSpec {
var title: String = ""
var body: String = ""
var priority: Int = 3
private val actions = mutableListOf<Action>()

fun action(init: Action.() -> Unit) {
actions += Action().apply(init)
}

class Action {
var label: String = ""
var intent: Intent? = null
}
}

fun notification(init: NotificationSpec.() -> Unit): NotificationSpec =
NotificationSpec().apply(init)

val spec = notification {
title = "Order shipped"
body = "Your order #1234 has left the warehouse"
priority = 2
action {
label = "Track"
intent = TrackActivity.intentFor(orderId = "1234")
}
}

@DslMarker — prevent scope bleeding

Without @DslMarker, nested DSL blocks can accidentally reach outer receivers:

@DslMarker
annotation class NotificationDsl

@NotificationDsl
class NotificationSpec { /* ... */ }

@NotificationDsl
class ActionSpec { /* ... */ }

notification {
action {
// Cannot accidentally access `title` from here — compile error
}
}

Inline classes (value classes)

Type-safe wrappers with zero runtime cost. Perfect for IDs, units, and opaque handles:

@JvmInline value class UserId(val raw: String)
@JvmInline value class Email(val raw: String) {
init { require(raw.matches(EMAIL_REGEX)) { "Invalid email: $raw" } }
}
@JvmInline value class Cents(val raw: Long) {
operator fun plus(other: Cents) = Cents(raw + other.raw)
val dollars: Double get() = raw / 100.0
}

// Before: fun charge(userId: String, amount: Long) { ... }
// Easy to mix up. After:
fun charge(userId: UserId, amount: Cents) { /* ... */ }

charge(UserId("u1"), Cents(4999))
// charge("u1", 4999L) // ERROR — type mismatch, bug prevented

Extension functions and properties

Extensions are the backbone of idiomatic Kotlin. Three patterns every Android codebase uses:

// 1. View / Context extensions
fun Context.dpToPx(dp: Int): Float = dp * resources.displayMetrics.density

fun View.showKeyboard() {
requestFocus()
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}

// 2. Flow extensions for Android
fun <T> Flow<T>.observeAsEvents(lifecycle: Lifecycle, block: suspend (T) -> Unit) {
lifecycle.coroutineScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect(block)
}
}
}

// 3. Kotlin stdlib-style extensions for collections
fun <T> List<T>.chunkedFlow(size: Int): Flow<List<T>> = chunked(size).asFlow()

Member extensions — scoped behavior

class DateFormatter(private val zone: ZoneId) {

// This extension is ONLY visible inside DateFormatter scope
fun Instant.formatted(): String = DateTimeFormatter.ISO_LOCAL_DATE_TIME
.format(this.atZone(zone).toLocalDateTime())

fun format(moments: List<Instant>): List<String> =
moments.map { it.formatted() } // extension visible here
}

Context receivers (preview)

Kotlin 2.x is previewing context(...) receivers — a cleaner replacement for extension-with-receiver hacks:

context(LocalContextReceivers)
fun loadImage(url: String) {
// Access `logger`, `dispatcher`, etc. from LocalContextReceivers
// without carrying them as parameters or fields
}

Watch for this maturing. Today, use explicit receivers and scope functions.

Delegation — by keyword

Kotlin delegation is two things: interface delegation (object composition) and property delegation (lazy, observable, map-backed).

// Interface delegation — no boilerplate forwarding
interface Logger { fun log(msg: String) }
class TimberLogger : Logger { override fun log(msg: String) = Timber.i(msg) }

class UserService(logger: Logger) : Logger by logger {
fun login() {
log("login start") // forwarded to delegate
}
}

// Property delegation
class ViewModelWithFlags(remote: RemoteConfig) {
val newCheckout by remote.flag("new_checkout_enabled", default = false)
}

class FlagDelegate<T>(
private val remote: RemoteConfig,
private val key: String,
private val default: T
) : ReadOnlyProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T =
remote.get(key) as? T ?: default
}

fun RemoteConfig.flag(key: String, default: Boolean) = FlagDelegate(this, key, default)

lazy, observable, vetoable

class Screen {
private val expensive by lazy { buildHeavyObject() } // constructed once, thread-safe

var counter: Int by Delegates.observable(0) { _, old, new ->
Log.d("Screen", "counter $old$new")
}

var positive: Int by Delegates.vetoable(0) { _, _, new -> new >= 0 }
}

Kotlin K2 compiler (what senior engineers know)

Kotlin 2.0 ships the K2 compiler — a complete rewrite of the frontend that:

  • Doubles compile speed in large projects
  • Enables new features (multi-file facades, better inference, smarter smart casts)
  • Preserves binary compatibility (use the same stdlib)

Enable K2:

# gradle.properties
kotlin.experimental.tryK2=true # Kotlin 1.9; default in 2.0+

K2 is stricter about some edge cases — you might see new warnings. Treat them as free code-review findings.

Key takeaways

Next

Return to the Module 01 Overview or continue to Module 02 — Android Fundamentals.