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
}

Leave a Reply

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