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.

How do I use Kotlin’s null safety features with collections and maps?

Kotlin’s null safety works especially well with collections and maps because Kotlin lets you distinguish between:

  • a nullable collection: List<String>?
  • a collection containing nullable values: List<String?>
  • both: List<String?>?

The same idea applies to maps.

1. Nullable collection vs nullable elements

val names: List<String>? = null
val nicknames: List<String?> = listOf("Ana", null, "Sam")
val maybeNicknames: List<String?>? = null

These mean different things:

List<String>?      // the list itself may be null
List<String?>      // the list exists, but elements may be null
List<String?>?     // both the list and its elements may be null

2. Safely access nullable collections

Use the safe-call operator ?. when the collection itself may be null.

val names: List<String>? = null

println(names?.size)        // null
println(names?.firstOrNull()) // null

If you want a default value, use the Elvis operator ?:.

val count = names?.size ?: 0

println(count) // 0

You can also use orEmpty() to treat a nullable collection as an empty one.

val names: List<String>? = null

for (name in names.orEmpty()) {
    println(name)
}

orEmpty() is often cleaner than repeated null checks.

3. Safely access elements

Avoid direct indexing unless you are sure the index exists.

val names = listOf("Ana", "Ben")

println(names[0]) // Ana
// println(names[5]) // IndexOutOfBoundsException

Use getOrNull() for safe access.

val names = listOf("Ana", "Ben")

val thirdName = names.getOrNull(2)

println(thirdName) // null

Combine it with Elvis for a default:

val displayName = names.getOrNull(2) ?: "Unknown"

println(displayName) // Unknown

4. Filter out null values

If a collection contains nullable elements, use filterNotNull().

val values: List<Int?> = listOf(1, null, 2, null, 3)

val nonNullValues: List<Int> = values.filterNotNull()

println(nonNullValues) // [1, 2, 3]

This is useful because Kotlin understands that the result no longer contains nullable values.

val names: List<String?> = listOf("Ana", null, "Ben")

val lengths = names
    .filterNotNull()
    .map { it.length }

println(lengths) // [3, 3]

5. Transform nullable values with mapNotNull

Use mapNotNull when your transformation may produce null and you only want valid results.

val inputs = listOf("1", "abc", "2", "", "3")

val numbers = inputs.mapNotNull { it.toIntOrNull() }

println(numbers) // [1, 2, 3]

This avoids writing:

val numbers = inputs
    .map { it.toIntOrNull() }
    .filterNotNull()

6. Use firstOrNull, singleOrNull, and find

Many Kotlin collection functions have safe nullable-returning versions.

val users = listOf("Ana", "Ben", "Chris")

val firstLongName = users.firstOrNull { it.length > 10 }
val foundUser = users.find { it.startsWith("B") }

println(firstLongName) // null
println(foundUser)     // Ben

Common safe functions include:

firstOrNull()
lastOrNull()
singleOrNull()
maxOrNull()
minOrNull()
randomOrNull()
getOrNull(index)

These return null instead of throwing when no value is available.

7. Handle nullable map values

Maps are slightly special because map[key] already returns a nullable value.

val ages: Map<String, Int> = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

val anaAge = ages["Ana"]      // Int?
val missingAge = ages["Sam"]  // Int?

println(anaAge)      // 28
println(missingAge)  // null

Even if the map type is Map<String, Int>, lookup returns Int? because the key might not exist.

Use Elvis to provide a default:

val samAge = ages["Sam"] ?: 0

println(samAge) // 0

8. Distinguish missing keys from null values

If your map value type is nullable, there are two possible meanings for null:

val scores: Map<String, Int?> = mapOf(
    "Ana" to 100,
    "Ben" to null
)

println(scores["Ben"]) // null
println(scores["Sam"]) // null

Both return null, but for different reasons:

  • "Ben" exists and has a null value
  • "Sam" does not exist

Use containsKey() when you need to distinguish them.

val key = "Ben"

if (scores.containsKey(key)) {
    println("Key exists with value: ${scores[key]}")
} else {
    println("Key does not exist")
}

9. Use getValue only when the key must exist

getValue() returns a non-nullable value if the map value type is non-nullable, but throws if the key is missing.

val ages = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

val age = ages.getValue("Ana")

println(age) // 28

But this throws:

val missing = ages.getValue("Sam") // NoSuchElementException

Use it when missing keys are a programming error, not normal data.

10. Safely work with nullable maps

If the map itself may be null, use ?., ?:, or orEmpty().

val ages: Map<String, Int>? = null

val anaAge = ages?.get("Ana") ?: 0

println(anaAge) // 0

Or iterate safely:

val ages: Map<String, Int>? = null

for ((name, age) in ages.orEmpty()) {
    println("$name is $age")
}

11. Combine map lookup with let

Use let to run code only when a lookup succeeds.

val ages = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

ages["Ana"]?.let { age ->
    println("Ana is $age years old")
}

If the key is missing, the block is skipped.

ages["Sam"]?.let { age ->
    println("Sam is $age years old")
}

12. Use safe casts with collections

When working with mixed data, use as? and filterIsInstance.

val items: List<Any?> = listOf("Kotlin", 42, null, "Java")

val strings = items.filterIsInstance<String>()

println(strings) // [Kotlin, Java]

For a single value:

val item: Any? = "Kotlin"

val text: String? = item as? String

println(text?.uppercase()) // KOTLIN

13. Common patterns

Default empty list

fun printNames(names: List<String>?) {
    names.orEmpty().forEach { name ->
        println(name)
    }
}

Remove nulls before processing

val emails: List<String?> = listOf("[email protected]", null, "[email protected]")

val normalized = emails
    .filterNotNull()
    .map { it.lowercase() }

println(normalized)

Safe map lookup with default

val settings = mapOf(
    "theme" to "dark"
)

val theme = settings["theme"] ?: "light"

println(theme)

Safe nested lookup

val users: Map<String, Map<String, String>> = mapOf(
    "ana" to mapOf("city" to "Paris")
)

val city = users["ana"]?.get("city") ?: "Unknown"

println(city) // Paris

Quick guide

Situation Use
Collection itself may be null collection?.size, collection.orEmpty()
Element may be null filterNotNull(), ?.let { }
Index may be invalid getOrNull(index)
Need first matching item safely firstOrNull { }, find { }
Transform and skip null results mapNotNull { }
Map key may be missing map[key] ?: default
Need to know if key exists containsKey(key)
Missing key should be an error getValue(key)
Nullable map iteration map.orEmpty()

In general, prefer safe calls, Elvis defaults, orEmpty(), filterNotNull(), mapNotNull(), and safe collection accessors like getOrNull() and firstOrNull() instead of using !! or assuming values are present.

How do I use takeIf and takeUnless for conditional evaluations in Kotlin?

In Kotlin, takeIf and takeUnless are scope-style functions used to keep or discard a value based on a condition.

takeIf

takeIf returns the object itself if the predicate is true; otherwise it returns null.

val result = value.takeIf { condition }

Equivalent to:

val result = if (condition) value else null

Example

val number = 10

val evenNumber = number.takeIf { it % 2 == 0 }

println(evenNumber) // 10

If the condition fails:

val number = 7

val evenNumber = number.takeIf { it % 2 == 0 }

println(evenNumber) // null

takeUnless

takeUnless is the opposite of takeIf.

It returns the object itself if the predicate is false; otherwise it returns null.

val result = value.takeUnless { condition }

Equivalent to:

val result = if (!condition) value else null

Example

val number = 7

val notEvenNumber = number.takeUnless { it % 2 == 0 }

println(notEvenNumber) // 7

If the condition is true:

val number = 10

val notEvenNumber = number.takeUnless { it % 2 == 0 }

println(notEvenNumber) // null

Common use with safe calls

Because both functions can return null, they are often used with ?.let.

val input = "kotlin"

input
    .takeIf { it.length > 3 }
    ?.let {
        println("Valid input: $it")
    }

This prints:

Valid input: kotlin

If the condition fails, let is skipped:

val input = "hi"

input
    .takeIf { it.length > 3 }
    ?.let {
        println("Valid input: $it")
    }

Nothing is printed.

Practical examples

Validate a string

fun normalizeUsername(username: String): String? {
    return username
        .trim()
        .takeIf { it.length >= 3 }
        ?.lowercase()
}

Usage:

println(normalizeUsername("  Alice  ")) // alice
println(normalizeUsername("  a  "))     // null

Reject blank input

val name = userInput.takeUnless { it.isBlank() }

This keeps userInput only if it is not blank.

Equivalent to:

val name = if (!userInput.isBlank()) userInput else null

Parse only valid values

val age = input
    .toIntOrNull()
    ?.takeIf { it >= 0 }

This gives you a non-negative integer or null.

println("42".toIntOrNull()?.takeIf { it >= 0 })  // 42
println("-5".toIntOrNull()?.takeIf { it >= 0 })  // null
println("abc".toIntOrNull()?.takeIf { it >= 0 }) // null

Important note

The predicate runs on the object itself, available as it.

val text = "Kotlin"

val result = text.takeIf { it.startsWith("K") }

Here, it is "Kotlin".

When to use which

Use takeIf when you want to keep a value if a condition is true:

val activeUser = user.takeIf { it.isActive }

Use takeUnless when you want to keep a value unless a condition is true:

val visibleUser = user.takeUnless { it.isDeleted }

In short:

value.takeIf { predicate }     // value if predicate is true, else null
value.takeUnless { predicate } // value if predicate is false, else null

How do I chain multiple scope functions together in Kotlin?

In Kotlin, scope functions can be chained because each one returns either:

  • the receiver object: also, apply
  • the lambda result: let, run, with

The key is understanding what each function returns.

Common chaining pattern

val result = User("Alice")
    .also {
        println("Created user: $it")
    }
    .apply {
        name = name.uppercase()
    }
    .let {
        "User name is ${it.name}"
    }

println(result)

Here:

  1. also receives the object as it and returns the same User
  2. apply receives the object as this and returns the same User
  3. let receives the object as it and returns the lambda result, a String

Example with a data class

data class User(var name: String, var age: Int = 0)

val description = User("Alice")
    .apply {
        age = 30
    }
    .also {
        println("Configured user: $it")
    }
    .run {
        "$name is $age years old"
    }

println(description)

Output:

Configured user: User(name=Alice, age=30)
Alice is 30 years old

Choosing the right scope function in a chain

Function Object reference Returns Common use
let it lambda result transform value, null checks
run this lambda result compute result from object
with this lambda result operate on existing object
apply this receiver object configure object
also it receiver object side effects like logging

Nullable chaining

Scope functions are especially useful with nullable values:

val length = maybeName
    ?.trim()
    ?.takeIf { it.isNotEmpty() }
    ?.also {
        println("Valid name: $it")
    }
    ?.let {
        it.length
    }

Here, the chain stops if any step returns null.

Practical example

val user = User(" alice ")
    .apply {
        name = name.trim()
    }
    .also {
        println("After trim: $it")
    }
    .apply {
        name = name.replaceFirstChar { it.uppercase() }
    }
    .also {
        println("Final user: $it")
    }

Rule of thumb

Use:

apply { }

when you want to configure an object and keep chaining the same object.

Use:

also { }

when you want to perform a side effect and keep chaining the same object.

Use:

let { }

or:

run { }

when you want to transform the object into another result.

So a typical chain often looks like:

val result = createObject()
    .apply {
        // configure object
    }
    .also {
        // log or validate
    }
    .let {
        // transform into final result
    }

How do I choose the right scope function for different use cases in Kotlin?

Kotlin scope functions all do the same broad thing: they execute a block of code in the context of an object. The main differences are:

  1. How you refer to the object: this or it
  2. What the function returns: the object itself or the block result

Quick decision table

Function Object reference Returns Best for
let it Lambda result Transforming a value, null-safe execution
run this Lambda result Computing a result from an object
with this Lambda result Grouping operations on an existing non-null object
apply this The original object Configuring or initializing an object
also it The original object Side effects like logging, validation, debugging

Use let when you want to transform a value

let is useful when you want to take an object and produce another value.

val name = "kotlin"

val length = name.let {
    it.uppercase().length
}

println(length) // 6

It is also very common with nullable values:

val email: String? = "[email protected]"

email?.let {
    println("Sending email to $it")
}

Use let when you are thinking:

“If this value exists, do something with it or turn it into something else.”


Use apply when you want to configure an object

apply returns the original object, so it is ideal for initialization.

data class User(
    var name: String = "",
    var age: Int = 0,
    var active: Boolean = false
)

val user = User().apply {
    name = "Alice"
    age = 30
    active = true
}

Inside apply, the object is available as this, so you can access its properties directly.

Use apply when you are thinking:

“Create this object and set it up.”


Use also when you want side effects without changing the chain result

also returns the original object, like apply, but the object is referenced as it.

This makes it good for logging, debugging, validation, or extra actions.

val numbers = mutableListOf(1, 2, 3)
    .also {
        println("Original list: $it")
    }
    .apply {
        add(4)
    }
    .also {
        println("Updated list: $it")
    }

println(numbers) // [1, 2, 3, 4]

Use also when you are thinking:

“Do this extra thing, but keep passing the same object along.”


Use run when you want to compute a result using an object

run uses this and returns the lambda result.

val message = StringBuilder().run {
    append("Hello, ")
    append("Kotlin")
    toString()
}

println(message) // Hello, Kotlin

This is useful when you want to perform multiple operations and return a final computed value.

Use run when you are thinking:

“Use this object to calculate a result.”


Use with when you already have an object and want to group operations

with is not called as an extension in the same style as the others. You pass the object as an argument.

val builder = StringBuilder()

val result = with(builder) {
    append("Hello, ")
    append("Kotlin")
    toString()
}

println(result) // Hello, Kotlin

Use with when you are thinking:

“With this existing object, do several things.”

with is usually best for non-null objects. For nullable values, prefer ?.let or ?.run.


The simplest way to choose

Ask two questions:

1. Do you want to return the original object?

If yes:

  • Use apply for configuration
  • Use also for side effects
val user = User().apply {
    name = "Alice"
}
val user = getUser().also {
    println("Loaded user: $it")
}

2. Do you want to return a new result?

If yes:

  • Use let when you prefer it
  • Use run when you prefer this
  • Use with when the object already exists and is non-null
val length = name.let {
    it.length
}
val text = builder.run {
    append("Done")
    toString()
}

Rule of thumb

let   = transform or null-check
run   = compute a result using this object
with  = operate on an existing object
apply = configure an object
also  = perform side effects

Common examples

Null-safe call

val username: String? = "sam"

username?.let {
    println("Username length: ${it.length}")
}

Best choice: let


Object setup

val request = Request().apply {
    method = "GET"
    url = "/users"
}

Best choice: apply


Logging in a chain

val result = loadUsers()
    .also { println("Loaded ${it.size} users") }
    .filter { it.active }

Best choice: also


Build a value from several operations

val summary = users.run {
    val activeCount = count { it.active }
    "Active users: $activeCount"
}

Best choice: run


Group repeated calls on one object

val result = with(StringBuilder()) {
    append("A")
    append("B")
    append("C")
    toString()
}

Best choice: with


Avoid overusing scope functions

Scope functions are useful, but chaining too many can make code harder to read:

val result = user
    .also { println(it) }
    .let { transform(it) }
    .also { save(it) }
    .run { toDto() }

Sometimes plain code is clearer:

println(user)

val transformed = transform(user)
save(transformed)

val result = transformed.toDto()

A good guideline is: use scope functions when they make intent clearer, not just to make code shorter.

How do I use run, apply, also, and with for cleaner Kotlin code?

In Kotlin, run, apply, also, and with are scope functions. They help make code cleaner by giving you a temporary scope around an object.

The main differences are:

Function Object reference Returns Best used for
apply this The original object Configuring an object
also it The original object Side effects, logging, validation
run this Lambda result Computing a result from an object
with this Lambda result Grouping operations on an object

apply: configure an object

Use apply when you want to initialize or configure an object and then keep using that same object.

val user = User().apply {
    name = "Alice"
    age = 30
    active = true
}

This is cleaner than:

val user = User()
user.name = "Alice"
user.age = 30
user.active = true

apply returns the original object:

val button = Button().apply {
    text = "Save"
    isEnabled = true
}

Use apply for:

  • object setup
  • builder-style initialization
  • setting multiple properties

also: perform side effects

Use also when you want to do something extra with an object without changing the main flow.

val user = repository.findUser(id)
    .also {
        logger.info("Loaded user: ${it.name}")
    }

also uses it and returns the original object.

Another example:

val numbers = mutableListOf<Int>()
    .also {
        println("Created list")
    }

Good use cases:

val file = File("data.txt")
    .also {
        require(it.exists()) { "File does not exist" }
    }

Use also for:

  • logging
  • debugging
  • validation
  • side effects that should not change the returned value

run: compute a result from an object

Use run when you want to execute code using an object and return a computed result.

val description = user.run {
    "$name is $age years old"
}

Here, run returns the last expression:

val length = "Kotlin".run {
    lowercase().length
}

run is useful when you want to avoid repeating the object name:

val isAdult = user.run {
    age >= 18
}

Use run for:

  • computing a value
  • using several properties/methods of an object
  • keeping temporary logic contained

with: group operations on an object

with is similar to run, but it is not called as an extension function.

val result = with(user) {
    "$name is $age years old"
}

This:

with(user) {
    println(name)
    println(age)
}

is cleaner than:

println(user.name)
println(user.age)

Use with when you already have an object and want to perform several operations with it.

val summary = with(order) {
    "Order $id has $itemCount items and costs $total"
}

Quick comparison

apply

val person = Person().apply {
    name = "Alice"
    age = 30
}

Meaning:

Configure this object and return the object.


also

val person = Person("Alice")
    .also {
        println("Created person: $it")
    }

Meaning:

Do something with this object and return the object.


run

val label = person.run {
    "$name ($age)"
}

Meaning:

Use this object to compute and return a result.


with

val label = with(person) {
    "$name ($age)"
}

Meaning:

With this object, compute and return a result.


Practical example

Without scope functions:

val request = Request()
request.url = "https://example.com"
request.method = "GET"
request.headers["Accept"] = "application/json"

logger.info("Created request: $request")

val summary = "Request to ${request.url} using ${request.method}"

With scope functions:

val request = Request().apply {
    url = "https://example.com"
    method = "GET"
    headers["Accept"] = "application/json"
}.also {
    logger.info("Created request: $it")
}

val summary = request.run {
    "Request to $url using $method"
}

Rule of thumb

Use this simple guide:

Need to configure an object?        apply
Need side effects like logging?     also
Need to compute a result?           run
Need to operate on an existing obj? with

Or more specifically:

  • Use apply when the lambda modifies or configures the receiver.
  • Use also when the lambda performs side effects and should not affect the returned value.
  • Use run when the lambda produces a new value.
  • Use with when you want to group operations on an existing object.

One warning: avoid overusing them

Scope functions can make code cleaner, but too many nested scope functions can make code harder to read.

Avoid this:

user.apply {
    address?.run {
        city.also {
            logger.info(it)
        }
    }
}

Prefer clearer code when nesting gets confusing:

val address = user.address ?: return
logger.info(address.city)

In short: scope functions are best when they reduce repetition and make intent obvious.

How do I use let to execute code only when a value is not null in Kotlin?

Use ?.let { ... }.

The safe-call operator ?. makes sure let is called only when the value is not null. Inside the let block, the value is available as a non-null value, usually named it.

val name: String? = "Kotlin"

name?.let {
    println("Name is $it")
    println("Length is ${it.length}")
}

If name is "Kotlin", the block runs.

If name is null, the block is skipped.

You can also give the value a clearer name:

val name: String? = "Kotlin"

name?.let { nonNullName ->
    println("Name is $nonNullName")
    println("Length is ${nonNullName.length}")
}

To handle the null case too, combine it with the Elvis operator ?::

val name: String? = null

name?.let {
    println("Name is $it")
} ?: println("Name was null")

So the general pattern is:

nullableValue?.let { value ->
    // Runs only when nullableValue is not null
}

How do I force unwrap a nullable value with !! in Kotlin (and why should I avoid it)?

In Kotlin, !! is the not-null assertion operator. It forcefully converts a nullable value like String? into a non-null value like String.

val name: String? = getName()

val length = name!!.length

This tells Kotlin:

“Trust me, name is not null.”

If name is actually null at runtime, Kotlin throws a NullPointerException.

val name: String? = null

println(name!!.length) // Throws NullPointerException

Why you should avoid !!

You should avoid !! because it bypasses Kotlin’s null-safety system. Kotlin’s nullable types exist specifically to help prevent null-related crashes, and !! effectively says: “ignore that safety check.”

Problems with !!:

  • It can cause runtime crashes.
  • It hides the fact that a value may be missing.
  • It often makes code less clear and less robust.
  • It usually means null handling should be improved.

Prefer safer alternatives

Use a safe call

val length = name?.length

If name is null, length becomes null.

Use Elvis operator for a default value

val length = name?.length ?: 0

If name is null, length becomes 0.

Use an explicit null check

if (name != null) {
    println(name.length)
} else {
    println("Name is missing")
}

Inside the if, Kotlin smart-casts name to a non-null String.

Use let

name?.let {
    println(it.length)
}

This only runs the block when name is not null.

Fail deliberately with a clearer message

If null truly represents a programmer error, prefer requireNotNull, checkNotNull, or an explicit error message:

val length = requireNotNull(name) { "Name must not be null" }.length

This still fails fast, but the error is much clearer than a generic NullPointerException.

When is !! acceptable?

Use !! only when you are absolutely certain the value cannot be null, and if it is null, that indicates a serious programming error.

Even then, this is usually better:

val user = requireNotNull(findUser(id)) {
    "Expected user with id=$id to exist"
}

Rule of thumb

If you are tempted to write this:

value!!

First ask whether one of these would be better:

value?.someCall()
value ?: defaultValue
if (value != null) { /* use value */ }
requireNotNull(value) { "Helpful error message" }

In most Kotlin code, !! should be rare.

How do I throw an exception when a value is null using ?: throw in Kotlin?

Use Kotlin’s Elvis operator ?: with throw on the right-hand side:

val value: String? = getNullableValue()

val nonNullValue: String = value ?: throw IllegalArgumentException("value must not be null")

Because throw is an expression in Kotlin, it can be used after ?:.

Example

fun printLength(text: String?) {
    val nonNullText = text ?: throw IllegalArgumentException("text must not be null")

    println(nonNullText.length)
}

If text is not null, it is assigned to nonNullText as a non-nullable String.
If text is null, the exception is thrown.

You can also use other exception types:

val id = nullableId ?: throw IllegalStateException("ID was unexpectedly null")

A common choice is:

  • IllegalArgumentException when a function argument is invalid
  • IllegalStateException when the object/program state is invalid