How do I use scope functions in a functional reactive context with Kotlin Flows?

In Kotlin Flow code, scope functions are useful, but they should usually play a supporting role. The main structure of your reactive pipeline should come from Flow operators such as map, filter, flatMapLatest, combine, onEach, catch, and stateIn.

A good rule of thumb:

Flow operators describe the stream.
Scope functions describe what you do with each value.

1. Use map for stream transformation, let for local value transformation

If you are transforming each emitted value, the outer operation should usually be map.

val userNames: Flow<String> =
    usersFlow.map { user ->
        user.let {
            "${it.firstName} ${it.lastName}"
        }
    }

In simple cases, let may be unnecessary:

val userNames: Flow<String> =
    usersFlow.map { user ->
        "${user.firstName} ${user.lastName}"
    }

Use let inside map when it clarifies a local transformation, especially for nullable values or multistep conversion.

val profileNames: Flow<String> =
    usersFlow.map { user ->
        user.profile?.let { profile ->
            profile.displayName
        } ?: "Anonymous"
    }

2. Use onEach for stream side effects, not also as the main Flow operator

For logging, analytics, caching, or debugging, prefer onEach.

val users: Flow<List<User>> =
    userRepository.users()
        .onEach { users ->
            logger.info("Loaded ${users.size} users")
        }

Inside a transformation, also can be fine when you want to return the same value after a local side effect:

val users: Flow<List<User>> =
    userRepository.users()
        .map { users ->
            users.filter { it.isActive }
                .also { activeUsers ->
                    logger.debug("Active users: ${activeUsers.size}")
                }
        }

But avoid using also where onEach expresses the intent better:

val users: Flow<List<User>> =
    userRepository.users()
        .onEach { logger.debug("Received users: $it") }
        .map { users -> users.filter { it.isActive } }

3. Use run when computing one result from an emitted object

run is useful when each emitted value needs a multistep computation.

val summaries: Flow<UserSummary> =
    usersFlow.map { user ->
        user.run {
            val fullName = "$firstName $lastName"
            val status = if (isActive) "active" else "inactive"

            UserSummary(
                id = id,
                name = fullName,
                status = status
            )
        }
    }

This works well when you want receiver-style access with this.

4. Use apply when constructing objects inside a Flow

apply is useful for configuring a mutable object before emitting or returning it.

val requests: Flow<Request> =
    userIds.map { userId ->
        Request().apply {
            method = "GET"
            path = "/users/$userId"
            headers["Accept"] = "application/json"
        }
    }

That said, in reactive code, immutable data classes are often clearer:

val requests: Flow<Request> =
    userIds.map { userId ->
        Request(
            method = "GET",
            path = "/users/$userId",
            headers = mapOf("Accept" to "application/json")
        )
    }

Use apply mainly when an API requires mutable configuration.

5. Use with sparingly inside Flow chains

with can be useful when working with an existing object, but nested receivers can become confusing inside Flow pipelines.

val messages: Flow<String> =
    events.map { event ->
        with(event.metadata) {
            "source=$source, timestamp=$timestamp"
        }
    }

This is fine if the receiver is obvious. But if you already have multiple nested lambdas, explicit names may be clearer:

val messages: Flow<String> =
    events.map { event ->
        val metadata = event.metadata
        "source=${metadata.source}, timestamp=${metadata.timestamp}"
    }

6. Be careful with nested it

Flow pipelines often contain nested lambdas. Scope functions can make that worse if every lambda uses implicit it.

Harder to read:

val result: Flow<List<String>> =
    usersFlow.map {
        it.filter {
            it.isActive
        }.map {
            it.name
        }
    }

Clearer:

val result: Flow<List<String>> =
    usersFlow.map { users ->
        users.filter { user ->
            user.isActive
        }.map { user ->
            user.name
        }
    }

This matters even more with scope functions:

val result: Flow<UserDto> =
    usersFlow.map { user ->
        user.profile?.let { profile ->
            UserDto(
                id = user.id,
                displayName = profile.displayName
            )
        } ?: UserDto(
            id = user.id,
            displayName = "Anonymous"
        )
    }

Prefer named lambda parameters when combining Flow operators and scope functions.

7. Use takeIf / takeUnless with care

Although not scope functions in the same group, takeIf and takeUnless often appear with let.

For simple filtering, prefer Flow’s filter:

val activeUsers: Flow<User> =
    usersFlow.filter { user ->
        user.isActive
    }

Instead of:

val activeUsers: Flow<User> =
    usersFlow.mapNotNull { user ->
        user.takeIf { it.isActive }
    }

But takeIf can be useful when a transformation may produce null:

val validEmails: Flow<String> =
    usersFlow.mapNotNull { user ->
        user.email
            ?.takeIf { email -> email.contains("@") }
            ?.lowercase()
    }

8. Use mapNotNull with let for nullable values

This is a widespread Flow pattern.

val avatars: Flow<Avatar> =
    usersFlow.mapNotNull { user ->
        user.avatarUrl?.let { url ->
            Avatar(url)
        }
    }

Or:

val displayNames: Flow<String> =
    usersFlow.mapNotNull { user ->
        user.profile?.displayName
    }

Use let when constructing a result from a nullable value is more involved.

9. Use flatMapLatest when the scope contains another Flow

If the transformation returns another Flow, do not use only let or map unless you intentionally want a nested Flow<Flow<T>>.

Usually:

val userDetails: Flow<UserDetails> =
    selectedUserId
        .filterNotNull()
        .flatMapLatest { userId ->
            userRepository.observeUserDetails(userId)
        }

If the ID is nullable, and you need fallback behavior:

val userDetails: Flow<UserDetails?> =
    selectedUserId.flatMapLatest { userId ->
        userId?.let {
            userRepository.observeUserDetails(it)
        } ?: flowOf(null)
    }

Here, let is handling the nullable value, while flatMapLatest handles the reactive flattening.

10. Prefer Flow operators for lifecycle and errors

Use catch, onStart, onCompletion, and retry rather than trying to encode those behaviors with scope functions.

val uiState: Flow<UiState> =
    userRepository.users()
        .map { users ->
            UiState.Success(users)
        }
        .onStart {
            emit(UiState.Loading)
        }
        .catch { throwable ->
            emit(UiState.Error(throwable.message ?: "Unknown error"))
        }

Scope functions can still help locally:

val uiState: Flow<UiState> =
    userRepository.users()
        .map { users ->
            users
                .filter { user -> user.isActive }
                .let { activeUsers -> UiState.Success(activeUsers) }
        }
        .onStart {
            emit(UiState.Loading)
        }
        .catch { throwable ->
            emit(UiState.Error(throwable.message ?: "Unknown error"))
        }

Practical mapping

Intent in Flow code Prefer Scope function role
Transform each emission map Use let/run inside if helpful
Remove nulls filterNotNull, mapNotNull Use let for nullable conversion
Side effect per emission onEach Use also only locally
Build/configure object map + constructor or apply apply for mutable setup
Switch to another Flow flatMapLatest, flatMapConcat, flatMapMerge Use let for nullable branch
Combine streams combine, zip Scope functions only inside result builder
Handle errors catch, retry Scope functions rarely needed
Emit loading state onStart Scope functions rarely needed

Example: realistic UI state pipeline

val uiState: StateFlow<UserUiState> =
    selectedUserId
        .filterNotNull()
        .flatMapLatest { userId ->
            userRepository.observeUser(userId)
        }
        .map { user ->
            user.run {
                UserUiState.Content(
                    id = id,
                    title = "$firstName $lastName",
                    subtitle = email ?: "No email"
                )
            }
        }
        .onEach { state ->
            analytics.logScreenState(state)
        }
        .catch { throwable ->
            emit(UserUiState.Error(throwable.message ?: "Unable to load user"))
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UserUiState.Loading
        )

Here:

  • filterNotNull handles nullable IDs.
  • flatMapLatest switches to the latest selected user stream.
  • run computes a UI model from a User.
  • onEach performs a side effect.
  • catch handles errors.
  • stateIn turns the cold flow into a StateFlow.

Main guideline

Use scope functions in Flow pipelines when they improve the readability of local value handling.

Avoid using them to replace Flow operators.

Good:
Flow operators for stream behavior.
Scope functions for per-value clarity.

Risky:
Long chains of map/let/also/run with nested it everywhere.

If the chain starts becoming hard to read, introduce named lambda parameters or local variables.

How do I avoid nullable types in complex data models using sealed classes and Result wrappers?

You can avoid “nullable everywhere” in complex data models by making missing/invalid/loading/error states explicit in the type system instead of representing them with null.

In Kotlin, the usual tools are:

  1. Sealed classes/interfaces for domain states and variants.
  2. Result<T>-style wrappers for success/failure.
  3. Non-nullable data classes for valid, fully constructed domain objects.
  4. Mapping layers from nullable external DTOs into safe domain models.

1. Avoid nullable domain fields

Instead of this:

data class User(
    val id: String?,
    val name: String?,
    val email: String?,
    val subscription: Subscription?
)

Prefer making the valid domain model non-nullable:

data class User(
    val id: UserId,
    val name: UserName,
    val email: Email,
    val subscription: SubscriptionState
)

@JvmInline
value class UserId(val value: String)

@JvmInline
value class UserName(val value: String)

@JvmInline
value class Email(val value: String)

Now User represents a valid user, not a partially valid object.


2. Use sealed classes for optional-like domain states

If a subscription can be absent, do not use:

val subscription: Subscription?

Use an explicit state:

sealed interface SubscriptionState {
    data object None : SubscriptionState

    data class Active(
        val plan: Plan,
        val renewalDate: RenewalDate
    ) : SubscriptionState

    data class Cancelled(
        val cancelledAt: CancelledAt
    ) : SubscriptionState
}

Then your model becomes:

data class User(
    val id: UserId,
    val name: UserName,
    val email: Email,
    val subscription: SubscriptionState
)

This avoids ambiguity:

subscription == null

could mean:

  • not loaded
  • user has no subscription
  • API forgot to send it
  • parsing failed
  • permission denied

A sealed class makes each state explicit.


3. Use sealed classes for loading/error states

Avoid UI or repository models like this:

data class UserScreenState(
    val user: User?,
    val isLoading: Boolean,
    val error: Throwable?
)

This allows invalid combinations:

user != null && isLoading == true && error != null

Instead:

sealed interface UserScreenState {
    data object Loading : UserScreenState

    data class Loaded(
        val user: User
    ) : UserScreenState

    data class Failed(
        val error: UserError
    ) : UserScreenState
}

Now impossible states are unrepresentable.

Usage:

fun render(state: UserScreenState) {
    when (state) {
        UserScreenState.Loading -> showLoading()

        is UserScreenState.Loaded -> showUser(state.user)

        is UserScreenState.Failed -> showError(state.error)
    }
}

No nullable checks needed.


4. Use Result wrappers for operations

For repository/service calls, avoid:

suspend fun getUser(id: String): User?

because null does not explain what happened.

Prefer:

suspend fun getUser(id: UserId): Result<User>

Usage:

val result = repository.getUser(userId)

result
    .onSuccess { user ->
        showUser(user)
    }
    .onFailure { throwable ->
        showError(throwable)
    }

However, Kotlin’s built-in Result<T> uses Throwable for failure. For richer domain errors, a custom result type is often better.


5. Prefer a custom domain Result for complex models

For complex systems, define your own result wrapper:

sealed interface AppResult<out T, out E> {
    data class Success<T>(
        val value: T
    ) : AppResult<T, Nothing>

    data class Failure<E>(
        val error: E
    ) : AppResult<Nothing, E>
}

Example domain errors:

sealed interface UserError {
    data object NotFound : UserError
    data object Unauthorized : UserError

    data class InvalidResponse(
        val reason: String
    ) : UserError

    data class NetworkFailure(
        val cause: Throwable
    ) : UserError
}

Repository:

interface UserRepository {
    suspend fun getUser(id: UserId): AppResult<User, UserError>
}

Usage:

when (val result = repository.getUser(userId)) {
    is AppResult.Success -> {
        val user = result.value
        showUser(user)
    }

    is AppResult.Failure -> {
        when (val error = result.error) {
            UserError.NotFound -> showNotFound()
            UserError.Unauthorized -> showUnauthorized()
            is UserError.InvalidResponse -> showInvalidResponse(error.reason)
            is UserError.NetworkFailure -> showNetworkError(error.cause)
        }
    }
}

This avoids both nullable success values and ambiguous failures.


6. Convert nullable DTOs at the boundary

External APIs, databases, and JSON often contain nullable fields. Keep that nullability in DTOs only.

Example DTO:

data class UserDto(
    val id: String?,
    val name: String?,
    val email: String?,
    val subscription: SubscriptionDto?
)

Then map to a safe domain model:

fun UserDto.toDomain(): AppResult<User, UserError> {
    val id = id ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing user id")
    )

    val name = name ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing user name")
    )

    val email = email ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing user email")
    )

    return AppResult.Success(
        User(
            id = UserId(id),
            name = UserName(name),
            email = Email(email),
            subscription = subscription.toDomainState()
        )
    )
}

Subscription mapping:

fun SubscriptionDto?.toDomainState(): SubscriptionState {
    if (this == null) {
        return SubscriptionState.None
    }

    return when (status) {
        "active" -> SubscriptionState.Active(
            plan = Plan(planName),
            renewalDate = RenewalDate(renewalDate)
        )

        "cancelled" -> SubscriptionState.Cancelled(
            cancelledAt = CancelledAt(cancelledAt)
        )

        else -> SubscriptionState.None
    }
}

In stricter systems, unknown statuses should return an error instead of None.


7. Model “not loaded” separately from “empty”

A common mistake is using nullable fields for lazy or partial loading:

data class Profile(
    val user: User,
    val orders: List<Order>?
)

Does orders == null mean “not loaded”, “failed”, or “user has no orders”?

Use a sealed class:

sealed interface LoadState<out T> {
    data object NotLoaded : LoadState<Nothing>
    data object Loading : LoadState<Nothing>

    data class Loaded<T>(
        val value: T
    ) : LoadState<T>

    data class Failed(
        val error: DomainError
    ) : LoadState<Nothing>
}

Then:

data class Profile(
    val user: User,
    val orders: LoadState<List<Order>>
)

An empty list now means truly loaded and empty:

Profile(
    user = user,
    orders = LoadState.Loaded(emptyList())
)

8. Use domain-specific alternatives to nullable primitives

Instead of:

data class Product(
    val discountPercent: Int?
)

Use:

sealed interface Discount {
    data object None : Discount

    data class Percentage(
        val value: Int
    ) : Discount
}

Then:

data class Product(
    val id: ProductId,
    val price: Money,
    val discount: Discount
)

This is clearer than checking whether discountPercent is null.


9. Combine sealed classes and result wrappers

A good pattern is:

sealed interface DataState<out T, out E> {
    data object Idle : DataState<Nothing, Nothing>
    data object Loading : DataState<Nothing, Nothing>

    data class Success<T>(
        val value: T
    ) : DataState<T, Nothing>

    data class Failure<E>(
        val error: E
    ) : DataState<Nothing, E>
}

Example:

data class UserViewModelState(
    val user: DataState<User, UserError>
)

Rendering:

fun render(state: UserViewModelState) {
    when (val userState = state.user) {
        DataState.Idle -> showIdle()
        DataState.Loading -> showLoading()

        is DataState.Success -> {
            showUser(userState.value)
        }

        is DataState.Failure -> {
            showUserError(userState.error)
        }
    }
}

10. Practical rule of thumb

Use nullable types only when null has exactly one obvious meaning.

Nullable may be okay here:

val middleName: String?

because “person has no middle name” is often obvious.

But avoid nullable here:

val user: User?
val error: Throwable?
val status: String?
val payment: Payment?
val permissions: List<Permission>?

because these often have multiple possible meanings.


Recommended structure

// External layer
data class UserDto(
    val id: String?,
    val name: String?,
    val email: String?
)

// Domain layer
data class User(
    val id: UserId,
    val name: UserName,
    val email: Email
)

sealed interface UserError {
    data object NotFound : UserError
    data object Unauthorized : UserError
    data class InvalidResponse(val reason: String) : UserError
}

sealed interface AppResult<out T, out E> {
    data class Success<T>(val value: T) : AppResult<T, Nothing>
    data class Failure<E>(val error: E) : AppResult<Nothing, E>
}

// Mapping boundary
fun UserDto.toDomain(): AppResult<User, UserError> {
    val id = id ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing id")
    )

    val name = name ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing name")
    )

    val email = email ?: return AppResult.Failure(
        UserError.InvalidResponse("Missing email")
    )

    return AppResult.Success(
        User(
            id = UserId(id),
            name = UserName(name),
            email = Email(email)
        )
    )
}

Summary

To avoid nullable types in complex data models:

  • Keep nullable fields in DTOs, not domain models.
  • Convert DTOs into non-null domain models at boundaries.
  • Use sealed classes for meaningful states.
  • Use Result or custom AppResult<T, E> for success/failure.
  • Model loading, missing, empty, failed, and unauthorized as separate states.
  • Make invalid states impossible to represent.

The core idea is:

// Avoid
val user: User?
val error: Throwable?

// Prefer
sealed interface UserState {
    data object Loading : UserState
    data class Loaded(val user: User) : UserState
    data class Failed(val error: UserError) : UserState
}

How do I use smart casting and flow control to eliminate redundant null checks in Kotlin?

In Kotlin, you can eliminate redundant null checks by letting the compiler smart cast a nullable value after you prove it is not null.

Basic smart cast

fun printLength(text: String?) {
    if (text != null) {
        println(text.length)
    }
}

Inside the if block, Kotlin knows text cannot be null, so it treats it as a non-null String.

You do not need this:

fun printLength(text: String?) {
    if (text != null) {
        if (text != null) {
            println(text.length)
        }
    }
}

The second check is redundant.

Use early returns for cleaner flow

A common Kotlin style is to return early when the value is null:

fun printLength(text: String?) {
    if (text == null) return

    println(text.length)
}

After the return, Kotlin knows that text must be non-null for the rest of the function.

This is useful when you want to avoid nesting:

fun processUserName(name: String?) {
    if (name == null) return

    println(name.uppercase())
    println(name.length)
}

Use Elvis with return

You can also combine the Elvis operator ?: with return:

fun processUserName(name: String?) {
    val nonNullName = name ?: return

    println(nonNullName.uppercase())
    println(nonNullName.length)
}

Here, if name is null, the function returns immediately. Otherwise, nonNullName is a non-null String.

Use Elvis with default values

If you want to continue with a fallback value instead of returning:

fun printLength(text: String?) {
    val value = text ?: ""

    println(value.length)
}

value is always a non-null String.

Use let for nullable scoped work

Use ?.let when you only want to run code if the value is non-null:

fun printLength(text: String?) {
    text?.let { nonNullText ->
        println(nonNullText.length)
    }
}

Inside the let block, nonNullText is non-null.

Smart casts with type checks

Smart casts also work with is checks:

fun printIfString(value: Any?) {
    if (value is String) {
        println(value.length)
    }
}

Inside the block, value is treated as String.

You can also invert the check:

fun printIfString(value: Any?) {
    if (value !is String) return

    println(value.length)
}

After the early return, Kotlin knows value is a String.

Combine conditions safely

Kotlin understands flow control in boolean expressions:

fun printLength(text: String?) {
    if (text != null && text.length > 3) {
        println(text.uppercase())
    }
}

Because text != null is checked first, text.length is safe.

This does not work if the order is reversed:

fun printLength(text: String?) {
    if (text.length > 3 && text != null) {
        println(text.uppercase())
    }
}

That fails because text.length is accessed before the null check.

Prefer immutable values

Smart casts work best with val values:

val name: String? = getName()

if (name != null) {
    println(name.length)
}

They may not work with mutable properties because the value could change between the check and the use:

var name: String? = getName()

if (name != null) {
    println(name.length)
}

Local var variables can sometimes be smart cast if the compiler can prove they are not modified, but mutable properties are more limited.

For properties, copy the value into a local val:

class User(var name: String?)

fun printUserName(user: User) {
    val name = user.name

    if (name != null) {
        println(name.length)
    }
}

Avoid !!

Instead of writing:

fun printLength(text: String?) {
    if (text != null) {
        println(text!!.length)
    }
}

write:

fun printLength(text: String?) {
    if (text != null) {
        println(text.length)
    }
}

The !! is unnecessary because smart casting already made text non-null.

Practical pattern

A concise, idiomatic pattern is:

fun handle(input: String?) {
    val text = input ?: return

    println(text.trim())
    println(text.length)
}

Use:

  • if (x != null) when you want a guarded block.
  • if (x == null) return when you want to avoid nesting.
  • val y = x ?: return when you want a non-null local variable.
  • x?.let { ... } when the work should happen only if x is non-null.
  • ?: defaultValue when you want to replace null with a fallback.

How do I combine scope functions with Kotlin DSLs for expressive code?

You combine scope functions with Kotlin DSLs by using each scope function for a clear role:

  • apply {} to configure DSL objects
  • also {} to log, validate, or attach side effects
  • run {} to produce a final value
  • let {} to transform intermediate values
  • with {} to operate on an existing DSL context

The most important DSL feature is the lambda with receiver:

Builder.() -> Unit

That lets your DSL block behave as if it is “inside” the builder object.


Basic pattern

A typical DSL builder function looks like this:

fun route(init: RouteBuilder.() -> Unit): Route {
    return RouteBuilder()
        .apply(init)
        .build()
}

Here:

RouteBuilder()
    .apply(init)

means:

Create a builder, run the DSL block against it, and keep the configured builder.

Then:

.build()

turns the builder into the final domain object.


Example: small HTTP route DSL

data class Route(
    val path: String,
    val method: String,
    val headers: Map<String, String>,
    val handlerName: String?
)

class RouteBuilder {
    var path: String = "/"
    var method: String = "GET"
    private val headers = mutableMapOf<String, String>()
    private var handlerName: String? = null

    fun header(name: String, value: String) {
        headers[name] = value
    }

    fun handler(name: String) {
        handlerName = name
    }

    fun build(): Route {
        return Route(
            path = path,
            method = method,
            headers = headers.toMap(),
            handlerName = handlerName
        )
    }
}

fun route(init: RouteBuilder.() -> Unit): Route {
    return RouteBuilder()
        .apply(init)
        .also {
            require(it.path.startsWith("/")) {
                "Route path must start with /"
            }
        }
        .build()
}

Usage:

val usersRoute = route {
    path = "/users"
    method = "GET"

    header("Accept", "application/json")
    handler("listUsers")
}

This reads like a small language:

route {
    path = "/users"
    method = "GET"
    header("Accept", "application/json")
    handler("listUsers")
}

Where scope functions fit

Use apply to configure builders

This is the most common pairing in Kotlin DSLs.

fun route(init: RouteBuilder.() -> Unit): Route {
    return RouteBuilder()
        .apply(init)
        .build()
}

Because apply:

  • uses this as the receiver
  • returns the same object

That matches DSL setup perfectly.


Use also for validation or logging

Use also when you want to inspect the builder without changing the chain result.

fun route(init: RouteBuilder.() -> Unit): Route {
    return RouteBuilder()
        .apply(init)
        .also {
            require(it.path.isNotBlank()) {
                "Route path cannot be blank"
            }
        }
        .build()
}

also keeps the configured RouteBuilder flowing into build().


Use run to compute the final result

You can use run when the final step is more than a direct build() call.

fun route(init: RouteBuilder.() -> Unit): Route {
    return RouteBuilder()
        .apply(init)
        .run {
            require(path.startsWith("/")) {
                "Route path must start with /"
            }

            build()
        }
}

Inside run, the builder is available as this, and the return value is the result of the block.

So this returns a Route, not a RouteBuilder.


Nested DSLs

Scope functions become especially useful when your DSL creates nested structures.

data class Page(
    val title: String,
    val sections: List<Section>
)

data class Section(
    val heading: String,
    val paragraphs: List<String>
)

class PageBuilder {
    var title: String = ""
    private val sections = mutableListOf<Section>()

    fun section(init: SectionBuilder.() -> Unit) {
        sections += SectionBuilder()
            .apply(init)
            .build()
    }

    fun build(): Page {
        return Page(
            title = title,
            sections = sections.toList()
        )
    }
}

class SectionBuilder {
    var heading: String = ""
    private val paragraphs = mutableListOf<String>()

    fun paragraph(text: String) {
        paragraphs += text
    }

    fun build(): Section {
        return Section(
            heading = heading,
            paragraphs = paragraphs.toList()
        )
    }
}

fun page(init: PageBuilder.() -> Unit): Page {
    return PageBuilder()
        .apply(init)
        .build()
}

Usage:

val page = page {
    title = "Kotlin DSLs"

    section {
        heading = "Introduction"
        paragraph("Kotlin DSLs are built with lambdas with receivers.")
        paragraph("Scope functions help keep builder code concise.")
    }

    section {
        heading = "Best Practices"
        paragraph("Use apply for configuration.")
        paragraph("Use run when returning a final computed value.")
    }
}

The key part is this:

sections += SectionBuilder()
    .apply(init)
    .build()

That pattern is the backbone of many Kotlin DSLs.


More expressive builder helpers

You can combine DSL functions with scope functions to keep code readable.

class FormBuilder {
    private val fields = mutableListOf<Field>()

    fun textField(name: String, init: TextFieldBuilder.() -> Unit = {}) {
        fields += TextFieldBuilder(name)
            .apply(init)
            .build()
    }

    fun build(): Form = Form(fields.toList())
}

data class Form(val fields: List<Field>)

data class Field(
    val name: String,
    val label: String,
    val required: Boolean
)

class TextFieldBuilder(
    private val name: String
) {
    var label: String = name
    var required: Boolean = false

    fun build(): Field {
        return Field(
            name = name,
            label = label,
            required = required
        )
    }
}

fun form(init: FormBuilder.() -> Unit): Form {
    return FormBuilder()
        .apply(init)
        .build()
}

Usage:

val signupForm = form {
    textField("email") {
        label = "Email address"
        required = true
    }

    textField("username") {
        label = "Username"
    }
}

Adding validation with also

fun form(init: FormBuilder.() -> Unit): Form {
    return FormBuilder()
        .apply(init)
        .build()
        .also {
            require(it.fields.isNotEmpty()) {
                "Form must contain at least one field"
            }
        }
}

This works, but note the validation happens after build() and validates the final Form.

If you want to validate the builder before building:

fun form(init: FormBuilder.() -> Unit): Form {
    return FormBuilder()
        .apply(init)
        .also {
            // validate builder state here
        }
        .build()
}

Transforming DSL output with let

Use let when you want to build something and then convert it.

val fieldNames = form {
    textField("email") {
        required = true
    }

    textField("username")
}.let { builtForm ->
    builtForm.fields.map { it.name }
}

Here, the DSL produces a Form, and let transforms it into a List<String>.


Using run for rendering

A common pattern is:

  1. Build a DSL object
  2. Render it to a final string
val html = page {
    title = "Kotlin"

    section {
        heading = "DSLs"
        paragraph("DSLs can make configuration expressive.")
    }
}.run {
    buildString {
        appendLine("# $title")

        sections.forEach { section ->
            appendLine()
            appendLine("## ${section.heading}")

            section.paragraphs.forEach { paragraph ->
                appendLine(paragraph)
            }
        }
    }
}

Here:

page { ... }

returns a Page.

Then:

.run { ... }

uses that Page to compute a rendered String.


Practical guideline

For DSL internals, the most common pattern is:

fun dsl(init: Builder.() -> Unit): Result {
    return Builder()
        .apply(init)
        .also {
            // optional validation, logging, debugging
        }
        .build()
}

For nested DSL elements:

fun child(init: ChildBuilder.() -> Unit) {
    children += ChildBuilder()
        .apply(init)
        .build()
}

For transforming the finished DSL result:

val output = dsl {
    // configuration
}.run {
    // render or compute final value
}

Avoid excessive nesting

This is expressive:

val config = server {
    port = 8080

    route {
        path = "/users"
        method = "GET"
    }
}

This is harder to follow:

val config = ServerBuilder().apply {
    RouteBuilder().apply {
        path = "/users"
    }.also {
        println(it)
    }.run {
        build()
    }.also {
        addRoute(it)
    }
}.run {
    build()
}

Prefer creating named DSL functions like route {} instead of exposing too many raw scope-function chains to DSL users.


Rule of thumb

Inside DSL builders:
apply = configure a builder
also  = validate, log, debug
run   = compute or build a final result
let   = transform a DSL result
with  = group operations on an existing context

A clean Kotlin DSL usually hides the scope functions inside the implementation, while exposing a readable API to callers:

val app = application {
    name = "Demo"

    server {
        port = 8080

        route {
            method = "GET"
            path = "/health"
        }
    }
}

Internally, that elegant syntax is often powered by simple patterns like:

Builder()
    .apply(init)
    .build()

How do I design APIs that take full advantage of Kotlin’s null safety features?

Design Kotlin APIs so that nullability communicates meaning, not uncertainty. A caller should be able to understand from the type alone whether a value is required, optional, absent, unknown, invalid, or failed.

1. Prefer non-null types by default

Use nullable types only when null is a valid part of the API contract.

fun sendEmail(address: String, subject: String, body: String)

This is better than:

fun sendEmail(address: String?, subject: String?, body: String?)

If the function cannot operate without those values, make them non-null. Kotlin will then prevent invalid calls at compile time.

2. Use nullable return types for genuine absence

Returning T? is appropriate when “not found” or “not available” is expected and simple.

interface UserRepository {
    fun findById(id: UserId): User?
}

This clearly tells callers:

A user may not exist for this ID.

The caller must handle that case:

val user = repository.findById(id)
    ?: return NotFound

3. Do not use null for errors

Use null for absence, not failure.

Avoid this:

fun parseUser(json: String): User?

This is ambiguous:

  • Was the user absent?
  • Was the JSON invalid?
  • Did parsing fail?

Prefer a result type:

fun parseUser(json: String): Result<User>

Or a domain-specific sealed type:

sealed interface ParseUserResult {
    data class Success(val user: User) : ParseUserResult
    data class InvalidJson(val message: String) : ParseUserResult
    data object MissingRequiredField : ParseUserResult
}

Then callers must handle every meaningful outcome.

4. Avoid nullable parameters when defaults work better

If a parameter has a reasonable fallback, use a default argument instead of accepting null.

Prefer:

fun greet(name: String = "Guest") {
    println("Hello, $name")
}

Instead of:

fun greet(name: String?) {
    println("Hello, ${name ?: "Guest"}")
}

With the first API, callers do this:

greet()
greet("Alice")

They do not need to pass null to mean “use the default”.

5. Use nullable parameters only when null has domain meaning

Nullable parameters are fine when null expresses a real option.

fun searchUsers(
    query: String,
    departmentId: DepartmentId? = null
)

Here, departmentId = null can clearly mean “search all departments”.

Even better, if the meaning is important, consider naming it explicitly:

fun searchUsers(
    query: String,
    departmentFilter: DepartmentId? = null
)

6. Consider explicit types instead of nullable booleans or flags

Avoid APIs like this:

fun loadUsers(includeInactive: Boolean?)

What does null mean?

Prefer an enum or sealed type:

enum class UserStatusFilter {
    ActiveOnly,
    InactiveOnly,
    All
}

fun loadUsers(statusFilter: UserStatusFilter = UserStatusFilter.ActiveOnly)

This is clearer and safer.

7. Avoid nested nullable structures where possible

Types like this are hard to use:

List<User?>?
Map<String, Address?>?
Result<User?>?

Ask what each nullable layer means.

For collections, prefer empty collections over nullable collections:

fun getUsers(): List<User>

Return:

emptyList()

instead of:

null

Use nullable elements only when individual elements can genuinely be missing:

fun getOptionalAnswers(): List<Answer?>

But in many APIs, this is better:

fun getAnswers(): List<Answer>

8. Model required object state with non-null properties

Prefer constructing valid objects from the start.

data class User(
    val id: UserId,
    val name: String,
    val email: Email
)

Avoid making properties nullable just because they are assigned later:

data class User(
    val id: UserId?,
    val name: String?,
    val email: Email?
)

If an object has different lifecycle states, model those states explicitly:

sealed interface Registration {
    data class Draft(
        val email: Email?
    ) : Registration

    data class Completed(
        val id: UserId,
        val email: Email,
        val verifiedAt: Instant
    ) : Registration
}

Now Completed cannot exist without the fields it requires.

9. Validate at API boundaries

When accepting external data, convert nullable or untrusted input into safe domain types as early as possible.

fun createUser(request: CreateUserRequest): CreateUserResult {
    val name = request.name?.takeIf { it.isNotBlank() }
        ?: return CreateUserResult.InvalidName

    val email = request.email?.let(::Email)
        ?: return CreateUserResult.InvalidEmail

    return CreateUserResult.Success(
        User(
            id = UserId.new(),
            name = name,
            email = email
        )
    )
}

Your internal domain model can then remain mostly non-null.

10. Use requireNotNull for programmer errors

If a value must be non-null for the function contract to make sense, fail early with a clear message.

fun configure(host: String?, port: Int?) {
    val validHost = requireNotNull(host) { "host is required" }
    val validPort = requireNotNull(port) { "port is required" }

    connect(validHost, validPort)
}

But if null is an expected user input case, return a validation result instead of throwing.

11. Avoid exposing platform types from Java interop

When wrapping Java APIs, do not leak uncertain nullability into your Kotlin API.

Java interop may produce platform types like:

String!

Wrap them with explicit Kotlin nullability:

class JavaUserDirectory(
    private val javaApi: JavaApi
) : UserDirectory {

    override fun findDisplayName(id: UserId): String? {
        return javaApi.lookupName(id.value)
    }
}

If the Java API guarantees non-null but Kotlin cannot see it, enforce it:

override fun getRequiredDisplayName(id: UserId): String {
    return requireNotNull(javaApi.lookupName(id.value)) {
        "Java API returned null display name for user $id"
    }
}

12. Use annotations for Java callers

If your Kotlin API is consumed from Java, nullability is less obvious at the call site. Consider ensuring generated Java signatures expose nullability annotations.

For example:

class UserService {
    fun findUser(id: UserId): User?
    fun createUser(name: String): User
}

Java callers will see nullability annotations in many toolchains, but make sure your build and documentation preserve them.

13. Make extension functions null-safe when appropriate

Kotlin lets you define extensions on nullable receivers. This can make APIs ergonomic.

fun String?.isNullOrBlankNormalized(): Boolean {
    return this == null || this.isBlank()
}

Use this when operating on nullable values is natural.

But avoid hiding important null handling. This may be too magical:

fun User?.sendWelcomeEmail()

A missing user is probably significant, so callers should handle it explicitly.

14. Design builders carefully

Builders often tempt developers into nullable mutable state:

class UserBuilder {
    var name: String? = null
    var email: Email? = null

    fun build(): User {
        return User(
            name = name!!,
            email = email!!
        )
    }
}

Prefer requiring mandatory values in the constructor or builder entry point:

class UserBuilder(
    private val name: String,
    private val email: Email
) {
    private var nickname: String? = null

    fun nickname(value: String) = apply {
        nickname = value
    }

    fun build(): User {
        return User(
            name = name,
            email = email,
            nickname = nickname
        )
    }
}

15. Avoid !! in public API implementations

The not-null assertion operator usually indicates that the API design is not expressing nullability well enough.

Avoid:

fun displayName(user: User?): String {
    return user!!.name
}

Prefer a non-null parameter:

fun displayName(user: User): String {
    return user.name
}

Or handle absence explicitly:

fun displayName(user: User?): String {
    return user?.name ?: "Unknown user"
}

16. Document the meaning of null

When an API uses T?, document what null means.

/**
 * Returns the user with the given ID, or null if no such user exists.
 */
fun findUser(id: UserId): User?

Avoid vague nullability. A nullable type should always have a clear semantic meaning.

17. Use naming conventions that reveal nullability semantics

Good names:

fun findUser(id: UserId): User?
fun currentUserOrNull(): User?
fun requireUser(id: UserId): User
fun getUser(id: UserId): User

Common convention:

  • find...: may return null
  • ...OrNull: explicitly nullable
  • require...: throws if missing
  • get...: often expected to return a value, but be consistent in your codebase

Example:

fun findUser(id: UserId): User?

fun requireUser(id: UserId): User {
    return findUser(id) ?: error("User not found: $id")
}

18. Prefer non-null callbacks unless absence is meaningful

Avoid making callback parameters nullable unless the callback itself is optional.

Prefer:

fun onUserLoaded(callback: (User) -> Unit)

For optional callback registration:

fun loadUser(
    id: UserId,
    onSuccess: (User) -> Unit,
    onNotFound: (() -> Unit)? = null
)

Or better, model the result:

fun loadUser(
    id: UserId,
    callback: (LoadUserResult) -> Unit
)

19. Use sealed results for complex absence states

If there are multiple “no value” cases, null is not enough.

Avoid:

fun getSession(): Session?

If there are several reasons:

  • user is not logged in
  • session expired
  • session is still loading
  • session failed to load

Use:

sealed interface SessionState {
    data object Loading : SessionState
    data object NotLoggedIn : SessionState
    data object Expired : SessionState
    data class Active(val session: Session) : SessionState
    data class Failed(val cause: Throwable) : SessionState
}

Then:

fun getSessionState(): SessionState

This gives callers exhaustive handling with when.

Practical rule of thumb

Use this decision table:

Situation API shape
Value is required T
Value may be absent T?
Collection may have no items List<T> with emptyList()
Operation may fail Result<T> or sealed result
Multiple absence/failure states sealed class/interface
Caller omitted optional config default argument
Invalid caller input validation result or require(...) depending on context
Java interop uncertainty wrap with explicit T or T?

Summary

To take full advantage of Kotlin null safety:

  • Make required values non-null.
  • Use T? only for meaningful absence.
  • Prefer empty collections over nullable collections.
  • Do not use null for errors.
  • Use default arguments instead of nullable parameters where possible.
  • Model complex states with sealed types.
  • Validate external input at boundaries.
  • Avoid !!.
  • Document what null means when you expose it.

Good Kotlin APIs make invalid states hard or impossible to represent.