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 returnnull...OrNull: explicitly nullablerequire...: throws if missingget...: 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
nullfor errors. - Use default arguments instead of nullable parameters where possible.
- Model complex states with sealed types.
- Validate external input at boundaries.
- Avoid
!!. - Document what
nullmeans when you expose it.
Good Kotlin APIs make invalid states hard or impossible to represent.
