Recommended resources
The freely available resources this lesson will direct you to.
Hello! This first module builds the Kotlin foundations you will use throughout the Android course and in coding interviews. Today’s focus is a small but consequential design decision: how should a screen represent loading, no results, results, and failure without relying on nullable fields and hoping every caller interprets them correctly?
We will use an articles screen as a running example. By the end, you should be able to distinguish an optional value within a result from the state of the entire request, model the four screen states with a sealed type, and handle each state without a not-null assertion.
Null is useful, but it needs a precise meaning
Suppose an article has an optional subtitle. A nullable property expresses that fact well: the article exists, but its subtitle may not.
data class Article(
val title: String,
val subtitle: String?
)
The distinction between String and String? gives the compiler something valuable to check. It will let you read title directly, but it requires you to account for a missing subtitle.
Null safety | Kotlin Documentation
Read Kotlin's “Null safety” documentation for the language rules behind the model we will build.
In “Nullable types and non-nullable types,” read nullable types. Then read “Check for null with the if conditional,” from its opening explanation through the note about when a value can change between a check and its use; focus on smart casts. In “Safe call operator,” read the opening examples through the chained-property example, focusing on what the expression returns when a receiver is null. Finally, read “Elvis operator” from its opening example through the explanation of the right-hand expression, and the opening explanation in “Not-null assertion operator.” You can stop before “Nullable receiver.”
For an optional subtitle, a fallback has a clear meaning:
fun subtitleLabel(article: Article): String =
article.subtitle ?: "No subtitle"
The Elvis operator uses "No subtitle" only when subtitle is null. A safe call is useful if you need to access a property of the optional value:
fun subtitleLength(article: Article): Int =
article.subtitle?.length ?: 0
You could also put the value in a local variable, check it, and let Kotlin smart-cast it to a non-null type inside the appropriate branch. These approaches handle the possibility of null. A not-null assertion (!!) instead tells the compiler to stop checking; if the value is null at runtime, the app crashes.
Now consider using List<Article>? for the whole screen. What would a null list mean—loading has not finished, loading failed, or no articles were found? The type alone cannot answer. An empty, non-null list might reasonably mean “loaded successfully, but found nothing.” Loading and failure are different facts again. Rather than assign several undocumented meanings to null, we should give those outcomes names.
Give each screen outcome its own type
A tempting first model combines independent fields:
data class LooseArticlesState(
val isLoading: Boolean,
val articles: List<Article>?,
val errorMessage: String?
)
It can represent the intended cases, but it can also represent confusing combinations: isLoading is true while errorMessage is present, or articles is null while neither loading nor an error is indicated. Every consumer must learn extra rules about which combinations are legitimate.
For a screen where these four outcomes are mutually exclusive, a sealed type makes the rule explicit. The Kotlin compiler knows its permitted variants and can check that a when expression handles all of them.
Read Kotlin's guide to sealed types for the exhaustiveness guarantee and its UI-state example.
In “Use sealed classes with when expression,” read exhaustive handling, then inspect the Error example immediately below it. In “Use case scenarios,” find “State management in UI applications”: read the introduction and the UIState and updateUI code directly beneath it. Stop before “Payment method handling.” Notice that the supplied data belongs only to the variant that needs it.
Here is our four-outcome model:
sealed interface ArticlesState {
data object Loading : ArticlesState
data object Empty : ArticlesState
data class Success(val articles: List<Article>) : ArticlesState
data class Failure(val message: String) : ArticlesState
}
Loading and Empty need no additional data, so each is a single object. Success carries the articles that can be displayed. Failure carries information needed to explain the failure. Unlike the loose model, a value cannot be both ArticlesState.Loading and ArticlesState.Failure.
The boundary where loaded data becomes screen state is also explicit:
fun stateForLoadedArticles(articles: List<Article>): ArticlesState =
if (articles.isEmpty()) {
ArticlesState.Empty
} else {
ArticlesState.Success(articles)
}
This function is called after a successful load. It does not treat an empty response as an error. Nor does it use null to stand for a failed request: a failure would be mapped to ArticlesState.Failure separately. Our convention is that Success contains at least one article; the List type itself does not enforce non-emptiness, so all producers of Success must respect that convention.
A consumer can then handle the state without guessing or asserting:
fun screenDescription(state: ArticlesState): String =
when (state) {
ArticlesState.Loading -> "Loading articles"
ArticlesState.Empty -> "No articles yet"
is ArticlesState.Success ->
"Showing ${state.articles.size} articles"
is ArticlesState.Failure -> state.message
}
There is no else branch. If you add a fifth variant later, this when expression will need a new branch to compile. Inside the Success branch, Kotlin knows that state has an articles property; there is no nullable list to force into a non-null value with !!.
This is an important interview-level distinction: null safety protects access to optional values; a sealed state model describes which business outcome occurred. They solve related, but different, problems.
Choose the model that matches the screen
Watch the following Android example after studying the code above. It distinguishes a repository’s success-or-failure result from the state exposed to a screen, then examines a limitation of making all screen state mutually exclusive.
How to Make Your Code Clean With Kotlin Sealed Classes
Watch “How to Make Your Code Clean With Kotlin Sealed Classes” by Philipp Lackner for its repository and UI-state example, and especially its discussion of when one exclusive state is too restrictive.
Watch repository and UI to see why a successful payload and a failure can carry different data, and why the UI may need a specific failure category. Continue with the trade-off: focus on the cached-data example, where the screen may need to show existing items while also reporting a refresh failure.
Our ArticlesState is a good fit if the product displays exactly one of those four conditions at a time. But imagine an articles screen that keeps previously downloaded articles visible while refreshing them. A refresh can fail and the cached articles can still be on screen. Replacing the entire screen with Failure would discard useful information.
The lesson is not “always use a sealed class for UI state” or “never use one.” Model the combinations the product actually needs. A screen with independent, simultaneous concerns may be better represented by a data class with shared properties and a sealed type for one part of the state. Conversely, making every property nullable merely to allow all combinations can admit states that make no sense. We will revisit those architecture choices later; for now, the four exclusive outcomes provide a clear contract.
One further production consideration: Failure(message: String) keeps this example focused, but an Android app often benefits from carrying a typed failure reason rather than a raw exception message. That lets the UI choose an appropriate, localized message without exposing technical details to users.
Key takeaways
Use nullable types for values that are genuinely optional, such as an article’s subtitle. Use ?., ?:, or a checked branch to work with them; do not use !! to bypass a missing case. When loading, empty, success, and failure are distinct, mutually exclusive outcomes, a sealed type names each one, keeps its relevant data with it, and enables exhaustive handling.
The next lesson looks more closely at data classes and immutable values—including what copying a state value does, and does not, copy.