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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.