Null Safety & Scope Functions
If you ask any Kotlin developer what they'd miss most going back to Java, most will say the same
thing: null safety. It is Kotlin's single most celebrated feature, and for good reason — a huge
share of Android crashes historically came down to one root cause, a NullPointerException
thrown at runtime because some reference the code assumed would always be present turned out to be
null. Kotlin doesn't just help you handle that case more gracefully; it makes the
entire category of bug visible to the compiler before the app ever runs. This
lesson covers how that works, and introduces Kotlin's scope functions — a small family of functions
(let, apply, also, run, with) that
make working with objects, including nullable ones, noticeably more readable.
Nullable vs. Non-Nullable Types
In Kotlin, every type comes in two flavors: a plain type like String, which can
never hold null, and a nullable version written with a trailing
question mark, String?, which explicitly can. This isn't a runtime check or a
convention — it's baked into the type system, so the compiler tracks it the same way it tracks
whether a variable is an Int or a String.
val trailName: String = "Bear Ridge Loop" // can never be null
val photoUri: String? = null // explicitly allowed to be null
// trailName = null // compiler error — won't even compile
// photoUri.length // compiler error — must handle null first
For an app like TrailLog, this distinction maps naturally onto the data itself. A Trail's
name is always going to be there — every logged hike has one. Its photoUri,
on the other hand, is genuinely optional: not every hike gets a photo attached. Modeling
photoUri as String? means the compiler forces every piece of code that
touches it to consider the "no photo" case — you cannot forget, because the code won't compile
until you do.
The Safe Call Operator: ?.
The safe call operator lets you call a method or read a property on a nullable reference without
manually checking for null first. If the value on the left is null, the
whole expression short-circuits to null instead of throwing — nothing crashes.
val trail: Trail? = repository.findTrailById(42)
// If trail is null, this whole line evaluates to null instead of crashing
val nameLength: Int? = trail?.name?.length
Chaining ?. works across as many nullable steps as needed — as shown above,
trail?.name?.length checks trail for null, then (only if it wasn't null)
checks name for null, before finally reading length. If anything along the
chain is null, the whole expression is null, safely, with no exception
thrown.
The Elvis Operator: ?:
Often a null result isn't good enough on its own — you want a sensible default instead.
That's what the Elvis operator (?:) is for: "use the value on the left, but if that's
null, use the value on the right instead."
val trail: Trail? = repository.findTrailById(42)
val displayName: String = trail?.name ?: "Unnamed Trail"
If trail is null, or if trail.name itself happens to be
null, displayName falls back to "Unnamed Trail" instead. Note
the result type: displayName is a plain, non-nullable String — the Elvis
operator's whole job is to take you from "might be null" back to "definitely not null."
The Not-Null Assertion: !! — Use Sparingly
Kotlin also gives you an escape hatch: the not-null assertion operator, !!, which tells
the compiler "trust me, this is never actually null" and unwraps a nullable type into its
non-nullable form.
val trail: Trail? = repository.findTrailById(42)
val name: String = trail!!.name // crashes with NPE if trail is actually null
!! should be used sparingly, and treated as a small red flag when
you see it in code review. Every other tool in this lesson exists specifically to let you handle a
null case safely — reaching for !! instead re-introduces the exact crash
risk that Kotlin's null safety was designed to eliminate. If trail really is
null when that line runs, !! throws a NullPointerException at
runtime, exactly like Java would have. A handful of situations genuinely justify it (a value you've
just checked for null two lines above, or Android framework interop where you know more than the
compiler does), but reaching for ?. or ?: first should be the default habit.
Scope Functions: let, apply, also, run, with
Kotlin's standard library includes five small functions whose entire job is to run a block of code
"in the scope of" an object, so you can read and write clearer code around that object. They differ
in two ways: whether the object is referred to as it or this inside the
block, and what the whole expression returns.
| Function | Object as | Returns | Typical use |
|---|---|---|---|
let | it | lambda result | run a block only if a nullable value is non-null |
apply | this | the object itself | configure an object's properties, then keep using it |
also | it | the object itself | a side effect (like logging) while passing the original object along |
run | this | lambda result | execute a block against an object and return a computed result |
with | this | lambda result | same as run, but called as a top-level function: with(obj) { ... } |
Worked Example: Safely Handling a Nullable Photo URI with ?.let { }
let combines beautifully with the safe call operator. value?.let { } reads
as "if value is not null, run this block with it" — the block simply never executes for
a null value, so there's no separate if check needed:
fun showTrailPhoto(trail: Trail) {
trail.photoUri?.let { uri ->
// This block only runs if photoUri is non-null
imageView.setImageURI(Uri.parse(uri))
imageView.visibility = View.VISIBLE
} ?: run {
// Runs instead, if photoUri was null
imageView.visibility = View.GONE
}
}
This single expression replaces what would otherwise be an if (trail.photoUri != null) { ... }
else { ... } block, while keeping the non-null value conveniently named (uri
here, instead of the default it) right where it's used.
Worked Example: Configuring a New Trail with apply
apply is ideal when you're setting several properties on a freshly created object and
want the whole block to read as "configure this, then hand it back":
val newTrail = Trail(
id = 0,
name = "",
notes = "",
photoUri = null,
latitude = 0.0,
longitude = 0.0,
distanceKm = 0.0,
date = System.currentTimeMillis()
).apply {
name = "Bear Ridge Loop"
notes = "Muddy after the rain, but great views at the top."
distanceKm = 6.4
latitude = 37.4275
longitude = -122.1697
}
Because apply refers to the object as this and returns the object itself,
every line inside the block reads as a plain property assignment, and newTrail ends up
holding the fully configured Trail — no separate variable, no repeated
newTrail.something = ... on every line.