These topics separate senior Kotlin developers from intermediate ones. Contracts, DSL builders, context receivers, and sealed hierarchies for error handling show up in senior-level interviews at companies that use Kotlin heavily.
Result<T> is a built-in inline class that wraps either a successful value or a Throwable. It replaces using nullable returns or try-catch for operations that can fail.
fun parseConfig(raw: String): Result<Config> {
return runCatching {
Json.decodeFromString<Config>(raw)
}
}
val config = parseConfig(rawJson)
.map { it.copy(debug = false) }
.getOrElse { Config.default() }
Result has functional operators — map, mapCatching, getOrElse, getOrDefault, onSuccess, onFailure, fold, and recover. One restriction: Result can’t be a direct return type of a suspend function because coroutines use Result internally.
Sealed hierarchies model a fixed set of outcomes that the compiler verifies exhaustively. Unlike exceptions, they make error cases explicit in the function signature and force callers to handle every case.
sealed interface NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>
data class HttpError(val code: Int, val message: String) : NetworkResult<Nothing>
data class NetworkError(val cause: Throwable) : NetworkResult<Nothing>
data object Loading : NetworkResult<Nothing>
}
fun handleResult(result: NetworkResult<User>) = when (result) {
is NetworkResult.Success -> showUser(result.data)
is NetworkResult.HttpError -> showError(result.message)
is NetworkResult.NetworkError -> showRetry()
is NetworkResult.Loading -> showLoader()
}
The Nothing type parameter on error subtypes lets them work with any NetworkResult<T>.
Result<T> wraps success or failure as Throwable. It’s good for simple pass/fail operations. Sealed classes let you define specific, typed error cases that callers must handle.
Use Result when errors are generic (parsing failed, network call failed). Use sealed hierarchies when errors are domain-specific and need different handling — InvalidCredentials should show different UI than NetworkFailure. Many codebases use sealed classes at the domain layer and Result at the infrastructure layer.
Class delegation lets a class implement an interface by forwarding all calls to a delegate object. The compiler generates the forwarding code at compile time.
interface Analytics {
fun trackEvent(name: String)
fun trackScreen(screen: String)
}
class AnalyticsLogger(
delegate: Analytics
) : Analytics by delegate {
override fun trackEvent(name: String) {
println("Event: $name")
delegate.trackEvent(name)
}
// trackScreen forwarded automatically
}
With inheritance, you’d extend the implementation class, locking yourself into it. With delegation, you can swap the delegate — pass FirebaseAnalytics in production and FakeAnalytics in tests. You can override specific methods while the rest are forwarded.
Interface delegation is Kotlin’s built-in support for the Decorator pattern. You implement an interface, delegate all calls to an existing implementation, and selectively override methods. The compiler handles the boilerplate — decorating a 10-method interface means writing overrides only for the methods you change.
A DSL (Domain-Specific Language) uses lambdas with receivers, extension functions, and operator overloading to create code that reads like a specialized language. The key feature is the lambda with receiver — T.() -> Unit — which lets code inside the lambda access T’s members directly.
fun buildHtml(init: HtmlBuilder.() -> Unit): String {
val builder = HtmlBuilder()
builder.init()
return builder.build()
}
val page = buildHtml {
head { title("My Page") }
body { p("Hello, world!") }
}
The standard library uses this pattern everywhere — buildList, apply, with, buildString. Frameworks like Ktor and Jetpack Compose are built on this concept.
@DslMarker prevents accidental access to outer receivers in nested DSL blocks. Without it, inner blocks can implicitly call methods from any enclosing receiver.
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class Table { fun tr(init: Row.() -> Unit) { ... } }
@HtmlDsl
class Row { fun td(text: String) { ... } }
// Without @DslMarker — compiles but is wrong
table {
tr {
tr { } // accidentally calling table's tr inside row
}
}
// With @DslMarker — compiler error on inner tr
When builder classes are annotated with @DslMarker, the compiler restricts implicit access to the closest receiver only. Every production DSL should use it.
A type-safe builder combines lambdas with receivers, builder classes, and @DslMarker.
@DslMarker
annotation class FormDsl
@FormDsl
class FormBuilder {
private val fields = mutableListOf<Field>()
fun textField(name: String, init: TextFieldBuilder.() -> Unit = {}) {
val builder = TextFieldBuilder(name)
builder.init()
fields.add(builder.build())
}
fun build(): Form = Form(fields)
}
@FormDsl
class TextFieldBuilder(private val name: String) {
var required: Boolean = false
var maxLength: Int = 255
fun build(): Field = Field(name, required, maxLength)
}
fun form(init: FormBuilder.() -> Unit): Form {
return FormBuilder().apply(init).build()
}
val loginForm = form {
textField("email") { required = true }
textField("password") { required = true; maxLength = 50 }
}
Contracts let you tell the compiler information it can’t infer on its own — for smarter type checking and control flow analysis. Without contracts, calling require(x != null) wouldn’t smart-cast x to non-null in subsequent code.
@OptIn(ExperimentalContracts::class)
fun requireUser(user: User?) {
contract {
returns() implies (user != null)
}
if (user == null) throw IllegalArgumentException("User required")
}
fun processUser(user: User?) {
requireUser(user)
println(user.name) // smart-cast to non-null
}
Standard library functions like require(), check(), checkNotNull() all have contracts.
callsInPlace tells the compiler how many times a lambda parameter will be invoked. This lets the compiler allow val initialization inside the lambda and verify definite assignment.
@OptIn(ExperimentalContracts::class)
inline fun <R> executeOnce(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
fun example() {
val result: String
executeOnce {
result = "initialized" // allowed because of callsInPlace
}
println(result) // compiler knows it's assigned
}
The four kinds are AT_MOST_ONCE, AT_LEAST_ONCE, EXACTLY_ONCE, and UNKNOWN. EXACTLY_ONCE is used by run, with, apply, also, let, and buildList.
returns() implies (condition) tells the compiler that if the function returns normally, the condition is true.
@OptIn(ExperimentalContracts::class)
fun isValidSession(session: Session?): Boolean {
contract {
returns(true) implies (session != null)
}
return session != null && !session.isExpired
}
fun loadDashboard(session: Session?) {
if (isValidSession(session)) {
showDashboard(session.userId) // smart-cast
}
}
This is how isNullOrEmpty() and isNullOrBlank() enable smart casting.
Context receivers let you declare that a function requires certain objects to be in scope without passing them as regular parameters.
context(Logger, TransactionScope)
fun processPayment(payment: Payment) {
log("Processing payment ${payment.id}")
execute("INSERT INTO payments ...")
}
with(logger) {
with(transactionScope) {
processPayment(payment)
}
}
They solve “parameter drilling” — passing the same dependency through many layers. The original context() syntax is being replaced with context(param: Type) in Kotlin 2.2+ as “context parameters.”
The compiler trusts contracts without verifying them — if your contract lies, the compiler won’t catch it, leading to unsound casts at runtime. Contracts can only be on top-level or member functions, not lambdas or local functions. The condition syntax is limited to implies, returns, and callsInPlace. They’re still @ExperimentalContracts because making them fully verifiable is a hard problem.
The compiler enforces exhaustive when on sealed types — you must handle every subtype or add else. Removing else is the point: adding a new sealed subtype produces compile errors at every unhandled when.
sealed interface AuthState {
data object Idle : AuthState
data object Loading : AuthState
data class Authenticated(val user: User) : AuthState
data class Error(val reason: AuthError) : AuthState
}
sealed interface AuthError {
data object InvalidCredentials : AuthError
data object NetworkFailure : AuthError
data object AccountLocked : AuthError
}
fun renderAuth(state: AuthState) = when (state) {
is AuthState.Idle -> showLoginForm()
is AuthState.Loading -> showSpinner()
is AuthState.Authenticated -> showHome(state.user)
is AuthState.Error -> when (state.reason) {
AuthError.InvalidCredentials -> showBadPassword()
AuthError.NetworkFailure -> showRetry()
AuthError.AccountLocked -> showContactSupport()
}
}
Adding AccountSuspended to AuthError breaks both when expressions at compile time.
Both decouple a class from its dependencies, but at different levels. Delegation is language-level — the compiler generates forwarding code. DI is architecture-level — a framework provides dependencies at runtime.
Delegation is for when a class needs to act as a particular interface. DI is for when a class needs to use a dependency without knowing how to create it. They work well together — use DI to provide the delegate instance, delegation to implement the interface.
class OfflineFirstRepository(
private val localSource: DataSource,
private val remoteSource: DataSource
) : DataSource by localSource {
override suspend fun getItems(): List<Item> {
return try {
remoteSource.getItems().also { localSource.saveItems(it) }
} catch (e: IOException) {
localSource.getItems()
}
}
}
Scope functions use the same mechanism — lambdas with receivers. apply and with use T.() -> Unit, making this the receiver. For DSLs, calling apply(init) on a builder instance provides the receiver scope.
fun notification(init: NotificationBuilder.() -> Unit): Notification {
return NotificationBuilder().apply(init).build()
}
// Uses the same mechanism as
val builder = NotificationBuilder().apply {
title = "New Message"
body = "You have 3 unread messages"
}
Scope functions configure a single object. DSLs create nested, structured configurations with multiple builder types.
Value classes wrap a single value without runtime allocation. The compiler replaces the wrapper with the underlying value at compile time.
@JvmInline
value class UserId(val id: String)
@JvmInline
value class OrderId(val id: String)
fun fetchOrder(userId: UserId, orderId: OrderId) { ... }
fetchOrder(orderId, userId) // won't compile — type safe
Common use cases: preventing parameter mixing, units of measurement (Meters vs Feet), validated wrappers. Boxing occurs when used as nullable, in collections, or as a generic type parameter.
@DslMarker on a scope function like apply?runCatching and try-catch in performance?