Skip to content
Commitwise
Menu
Programming

Null Safety: Optionals and Nullable Types

Handle values that may be missing without crashing, with optionals in Swift, nullable types in Kotlin and Optional in Java.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The most common crash in a mobile app isn’t a clever race condition. It’s this:

Fatal error: Unexpectedly found nil while unwrapping an Optional value     ← Swift
java.lang.NullPointerException                                             ← Kotlin and Java

Somewhere, code expected a value and got nothing. This tutorial shows how Swift, Kotlin and Java let you say “this may be missing” in the code itself, and how to handle the missing case before it reaches your users.

New to some of these? Open what you need, or skip ahead.

Basics:What are variables and types?

A variable is a named box that holds a value: let age = 30 (Swift), val age = 30 (Kotlin), int age = 30; (Java), const age = 30 (TypeScript). The type says what kind of value fits in the box: Int / int for whole numbers, Double for decimals, String for text, Bool / Boolean for true or false.

  • let (Swift), val (Kotlin) and final (Java) mean the variable can’t be pointed at another value later. var means it can.
  • Swift, Kotlin and TypeScript can often infer the type from the value, so you don’t have to write it. Java needs it written (or var for local variables). TypeScript’s const is like let / val, and its let is like var.
Basics:What are functions and parameters?

A function is a named piece of code you can run again and again. It takes parameters (inputs) and can return a result:

Swift:   func add(_ a: Int, _ b: Int) -> Int { a + b }
Kotlin:  fun add(a: Int, b: Int): Int = a + b
Java:    static int add(int a, int b) { return a + b; }
TS:      function add(a: number, b: number): number { return a + b; }

Calling it, add(2, 3), runs the code with a = 2 and b = 3 and gives back 5. A function that belongs to a class is usually called a method.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data.
Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is boxing (int vs Integer)?

Java has fast primitive types like int, double and boolean, which aren’t objects. Collections and generics can only hold objects, so Java wraps primitives in objects: int → Integer, double → Double. That wrapping is called boxing; reading the number back out is unboxing.

Two things to know: an Integer can be null (and unboxing null crashes), and comparing two Integers with == compares the objects, not the numbers. Kotlin hides most of this (its Int becomes int or Integer as needed), and Swift has no boxing at all for its number types.

Basics:What do annotations like @Service mean?

An annotation (the @Something in Java and Kotlin) attaches extra information to a class, method, field or parameter. It doesn’t run code by itself; tools and frameworks read it and act on it.

@Service tells Spring “create one of these and manage it”, @GetMapping("/books") says “call this method for GET requests to /books”, @Entity tells Hibernate “this class is a database table”. Swift has a related idea in attributes and property wrappers, like @State and @MainActor.

Part Not started

Values that may be missing

Think of a variable as a parcel locker. Most of the time there’s a parcel inside, but sometimes the locker is empty. If you reach in and try to open a parcel that isn’t there, things go wrong. In code, “empty” is called nil (Swift) or null (Kotlin and Java), and trying to use it crashes the app.

Real data is full of empty lockers: a user without a middle name, a product without a discount, an API response without an avatar URL. Here’s how that usually goes wrong. You show a user’s initials, and test it with your own name, Marko Petar Horvat:

let initials = user.firstName.prefix(1) + user.middleName.prefix(1)
// error: value of optional type 'String?' must be unwrapped
val initials = user.firstName.take(1) + user.middleName.take(1)
// error: only safe (?.) or non-null asserted (!!.) calls are allowed
String initials = user.firstName().substring(0, 1) + user.middleName().substring(0, 1);
// compiles, works for Marko, crashes for Ana
const initials = user.firstName.slice(0, 1) + user.middleName.slice(0, 1);
// error: 'user.middleName' is possibly 'null'

Java

It works on your machine, passes code review and ships. Then Ana, who has no middle name, opens her profile: middleName() is null, and calling substring on null throws NullPointerException. The app closes in front of her.

Swift, Kotlin, TypeScript

The compiler stops you right there, because middleName may be missing. That error is the crash Ana would have hit after release (Ana has no middle name), caught before it ships.

You can’t avoid missing values, so the goal is to make sure you never forget to check.

That’s what null safetyNull safetyA language feature that makes the compiler check where a value might be missing, so you can't use it by accident.Open in glossary → does: the type of a variable tells you whether it can be empty, and the compiler refuses to let you use a maybe-empty value without handling the empty case first.

Tony Hoare called the null reference his “billion-dollar mistake”: in Java, every reference type silently includes null, so the type String really means “a String, or nothing”. Swift and Kotlin split that into two types, String and String?, and make the conversion from the second to the first explicit. The crash moves from runtime to a compile error you fix once.

The interesting parts for experienced developers are where that guarantee leaks: interop with Java and Objective-C, force unwraps, mutable properties that defeat smart casts, and Optional misuse in Java. They’re covered part by part below.

Declaring a value that may be missing

Add a ? to the type. In Java there’s no ?: any reference can be null, and the type doesn’t tell you. TypeScript (with strict on, as in every new project) spells it as a union: string | null.

struct User {
    let firstName: String
    let middleName: String?   // may be nil
}

let ana = User(firstName: "Ana", middleName: nil)
data class User(
    val firstName: String,
    val middleName: String?,   // may be null
)

val ana = User(firstName = "Ana", middleName = null)
// Any reference may be null; the type doesn't say which ones
record User(String firstName, String middleName) {}

var ana = new User("Ana", null);
interface User {
  firstName: string;
  middleName: string | null;   // may be null
}

const ana: User = { firstName: "Ana", middleName: null };

TypeScript note

TypeScript has two empty values. null means “deliberately empty” (and it’s what JSON uses); undefined means “never set”, and it’s what you get from a missing property, a missing map key or ?.. An optional property, middleName?: string, is string | undefined. Checking value == null (two equals signs) catches both.

Java note

Java has two partial fixes: the Optional<T> wrapper for return values, and nullability annotations such as JSpecify’s @Nullable, which tools and Kotlin can check. Both appear below.

Notice that firstName has no ? (or | null). In Swift, Kotlin and TypeScript that’s a promise: it can never be empty, so you can use it anywhere without checking. Only middleName needs care.

Swift, Kotlin

String? is a different type from String, and getting from one to the other is always explicit: passing nil / null where a String is expected doesn’t compile.

Java

The type String silently includes null. The only signals are Optional<T> (for return values) and annotations like JSpecify’s @Nullable, which static analysis and Kotlin read but the JVM ignores.

TypeScript

With strictNullChecks (part of strict), string excludes null and undefined, and string | null must be narrowed before use. Without it, null fits every type, as in Java. Two empty values: null (deliberate, and what JSON uses) and undefined (missing property, missing key, the result of ?.); x == null matches both.

SwiftKotlinJavaTypeScript
DeclareString?String?@Nullable String, Optional<String> (returns)string | null, name?: string

Swift

Refresher:What String? really is

Swift’s String? is shorthand for Optional<String>, an enum with case none and case some(String). ?. is sugar for flatMap, and if let is pattern matching on .some.

Kotlin

Refresher:What String? really is

Kotlin’s String? is the same JVM reference as String; nullability exists only at compile time, plus a parameter check the compiler adds for Java callers. Int? is boxed as java.lang.Integer.

Java

Refresher:Optional vs @Nullable

Optional<String> is an ordinary wrapper object, one allocation per call. @Nullable is only an annotation: free at runtime, but only as good as the tools that check it.

Check this part

  1. let a: String and let b: String?. Which one can hold nil?val a: String and val b: String?. Which one can hold null?String a and String b. Which one can hold null?let a: string and let b: string | null. Which one can hold null?

  2. How do you say that a value may be missing?Which Java tool says in a method's return type that it may find nothing?

  3. How do you get from String? to String?What does a Java variable of type String actually admit?How do you get from string | null to string?

  4. A function takes name: String. Can a caller in your Swift code pass nil?A function takes name: String. Can a caller in your Kotlin code pass null?Besides Optional for return values, what tells readers and tools that a Java value may be null?A project has strict (and so strictNullChecks) turned off. Can you pass null where a string is expected?

Swift and Kotlin mark values that may be missing with ? in the type, and the compiler makes you handle them. In Java any reference may be null.

Part Not started

Reading it safely

There are four everyday tools. You’ll use all of them.

// 1. Unwrap into a constant you can use
if let middle = ana.middleName {
    print("Middle name: \(middle)")
}

// 2. Leave early when it's missing
func initials(of user: User) -> String {
    guard let middle = user.middleName else {
        return String(user.firstName.prefix(1))
    }
    return "\(user.firstName.prefix(1))\(middle.prefix(1))"
}

// 3. Optional chaining: the result is optional too
let length = ana.middleName?.count      // Int?, here nil

// 4. A default value
let shown = ana.middleName ?? "-"       // String, here "-"
// 1. Check, then use: the compiler smart-casts middle to String
val middle = ana.middleName
if (middle != null) {
    println("Middle name: $middle")
}

// 2. Leave early when it's missing
fun initials(user: User): String {
    val middle = user.middleName ?: return user.firstName.take(1)
    return user.firstName.take(1) + middle.take(1)
}

// 3. Safe call: the result is nullable too
val length = ana.middleName?.length     // Int?, here null

// 4. A default value with the Elvis operator
val shown = ana.middleName ?: "-"       // String, here "-"
// 1. Check before you use it (the compiler won't remind you)
String middle = ana.middleName();
if (middle != null) {
    System.out.println("Middle name: " + middle);
}

// 2. Leave early when it's missing
static String initials(User user) {
    String middle = user.middleName();
    if (middle == null) return user.firstName().substring(0, 1);
    return user.firstName().substring(0, 1) + middle.substring(0, 1);
}

// 3. Optional.map runs only when there is a value
Optional<Integer> length = Optional.ofNullable(ana.middleName()).map(String::length);   // Optional.empty

// 4. A default value
String shown = Optional.ofNullable(ana.middleName()).orElse("-");   // "-"
// 1. Check, then use: TypeScript narrows middle to string
const middle = ana.middleName;
if (middle !== null) {
  console.log(`Middle name: ${middle}`);
}

// 2. Leave early when it's missing
function initials(user: User): string {
  const middle = user.middleName;
  if (middle === null) return user.firstName.slice(0, 1);
  return user.firstName.slice(0, 1) + middle.slice(0, 1);
}

// 3. Optional chaining: the result may be undefined
const length = ana.middleName?.length;   // number | undefined, here undefined

// 4. A default value
const shown = ana.middleName ?? "-";     // string, here "-"
  1. Unwrap when you need the value. if let in Swift, a != null check in Kotlin and Java, !== null in TypeScript. Inside the block the value is guaranteed to be there.
  2. Exit early when there’s nothing to do. guard let in Swift, ?: return in Kotlin and if (x === null) return in TypeScript keep the “happy path” unindented and easy to read.
  3. Chain when you only need a part of it. Optional chainingOptional chainingReading a value through a chain of properties that may be missing, getting nothing back instead of a crash.Open in glossary → (?.) reads a property only if the value exists, otherwise the whole expression is empty too.
  4. Fall back to a default when an empty value has a sensible replacement: ?? in Swift and TypeScript, ?: (the “Elvis operator”, turn it sideways) in Kotlin.
  • Kotlin’s if (middle != null) relies on a smart castSmart castKotlin treats a value as a more specific type after you've checked it, without an explicit cast.Open in glossary →, which only works when the compiler can prove the value didn’t change between the check and the use. That’s why the example copies the property into a local val first; more on that in the Detailed version.
  • guard let … else must exit the scope (return, throw, continue, break or a Never call), which is exactly what makes it safe. Kotlin’s ?: return and ?: throw give the same shape as an expression.
  • In Java, Optional is meant for return types. For locals and fields an explicit null check is clearer and allocation-free.
  • In TypeScript, older code uses || for defaults, which also replaces 0, "" and NaN: count || 1 turns a real 0 into 1. ?? (and != null) mean “missing” only.

Predict the output

What do these two lines print?

let nickname: String? = nil
print("Hi, " + (nickname ?? "friend"))
print(nickname?.count)
val nickname: String? = null
println("Hi, " + (nickname ?: "friend"))
println(nickname?.length)
String nickname = null;
System.out.println("Hi, " + Optional.ofNullable(nickname).orElse("friend"));
System.out.println(Optional.ofNullable(nickname).map(String::length));
const user: { nickname?: string } = {};
console.log("Hi, " + (user.nickname ?? "friend"));
console.log(user.nickname?.length);

Smart casts on mutable properties

class Profile(var nickname: String?)

fun show(p: Profile) {
    if (p.nickname != null) {
        println(p.nickname.length)   // Error: smart cast to 'String' is impossible
    }
    p.nickname?.let { println(it.length) }   // OK: 'it' is a stable local value
}

Another thread (or a custom getter) could change nickname between the check and the use, so the compiler refuses. Copy to a local val or use ?.let.

Practice: checking a sign-up form

Form fields are a perfect place to practice, because everything a user types can be missing or wrong. Here’s a validator for a sign-up form with an optional email and an age typed as text:

struct SignUpForm {
    var email: String?
    var age: String?
}

func validate(_ form: SignUpForm) -> String {
    guard let email = form.email, !email.isEmpty else {
        return "Email is required"
    }
    guard let ageText = form.age,
          let age = Int(ageText.trimmingCharacters(in: .whitespaces)) else {
        return "Age must be a number"
    }
    return age >= 16 ? "Welcome, \(email)!" : "Sorry, you must be 16 or older"
}
data class SignUpForm(val email: String?, val age: String?)

fun validate(form: SignUpForm): String {
    val email = form.email?.takeIf { it.isNotEmpty() }
        ?: return "Email is required"
    val age = form.age?.trim()?.toIntOrNull()
        ?: return "Age must be a number"
    return if (age >= 16) "Welcome, $email!" else "Sorry, you must be 16 or older"
}
record SignUpForm(String email, String age) {}

static String validate(SignUpForm form) {
    String email = form.email();
    if (email == null || email.isEmpty()) {
        return "Email is required";
    }
    Integer age = parseIntOrNull(form.age());
    if (age == null) {
        return "Age must be a number";
    }
    return age >= 16 ? "Welcome, " + email + "!" : "Sorry, you must be 16 or older";
}

static Integer parseIntOrNull(String text) {
    if (text == null) return null;
    try {
        return Integer.parseInt(text.trim());
    } catch (NumberFormatException e) {
        return null;
    }
}
interface SignUpForm {
  email?: string;
  age?: string;
}

function validate(form: SignUpForm): string {
  const email = form.email;
  if (!email) {
    return "Email is required";   // missing or ""
  }
  const age = parseIntOrNull(form.age);
  if (age === null) {
    return "Age must be a number";
  }
  return age >= 16 ? `Welcome, ${email}!` : "Sorry, you must be 16 or older";
}

function parseIntOrNull(text: string | undefined): number | null {
  if (text === undefined || text.trim() === "") return null;
  const n = Number(text);
  return Number.isInteger(n) ? n : null;
}

Read it from top to bottom, one step at a time:

  1. Check, then leave early. Each missing value ends the function right away with a clear message. This is called an early exit, and it keeps the happy path at the bottom, without nested ifs.
  2. After the check, the value is safe. In Swift, email below the guard is a plain String. In Kotlin, email is a String because the ?: return already handled null. TypeScript narrows it the same way after the if. Java can’t track this for you: the safety comes only from the check you wrote.
  3. Turning text into a number can fail too. Int(...), toIntOrNull() and our parseIntOrNull all answer “maybe a number”. Java’s Integer.parseInt throws instead, so we wrapped it. TypeScript’s Number(...) never throws; it returns NaN (“not a number”) for bad text, and 0 for an empty string, which is why our version checks both.

Try the validator in your head with a few inputs:

emailageResult
missing"20"Email is required
"ana@example.com""twenty"Age must be a number
"ana@example.com"" 20"Welcome, ana@example.com!
"ana@example.com""15"Sorry, you must be 16 or older

Why did we trim the text before parsing? Find out:

Predict the output

The user typed a space before the number. What happens?

let typed = " 42"
print(Int(typed))
val typed = " 42"
println(typed.toIntOrNull())
String typed = " 42";
System.out.println(Integer.parseInt(typed));
const typed = " 42";
console.log(Number(typed));

Practice: a badge that says “null”

Optional chaining is great, but the result is still optional. Forgetting that is one of the most common bugs in code reviews:

Spot the bugNotification badge

Tap the line with the bug.

func badgeText(for user: User?) -> String {
    let count = user?.notifications.count
    if count == 0 { return "" }
    return "\(count) new"
}
fun badgeText(user: User?): String {
    val count = user?.notifications?.size
    if (count == 0) return ""
    return "$count new"
}
static String badgeText(User user) {
    Integer count = user == null ? null : user.notifications().size();
    if (count == 0) return "";
    return count + " new";
}
function badgeText(user: User | undefined): string {
  const count = user?.notifications.length;
  if (count === 0) return "";
  return count + " new";
}

SwiftKotlinJavaTypeScript
Safe accessa?.ba?.bopt.map(A::b)a?.b (gives undefined)
Defaulta ?? x (lazy)a ?: x (lazy)orElse(x) (eager), orElseGet(() -> x)a ?? x (lazy; not ||)
Unwrap or exitguard let a else { return }val a = a ?: returnif (a == null) return;if (a == null) return; (narrows)

Swift

  • guard let … else must leave the scope (return, throw, continue, break or a Never call), which is why the unwrapped value exists below it.
  • ?? takes its right side as an autoclosure, so the default is only computed when the left side is nil.

Kotlin

  • ?: return and ?: throw unwrap for the rest of the function: below val name = user.name ?: return, name is a String.
  • Smart casts need a stable value: if (p.nickname != null) p.nickname.length doesn’t compile for a var property, because it could change between the check and the use. Copy it into a local val or use ?.let.

Java

  • Optional is for return types. For locals and fields an explicit null check is clearer and allocation-free.
  • map skips an empty Optional, and orElse supplies the default, but orElse(x) evaluates x even when a value exists: use orElseGet for anything expensive.

TypeScript

  • Narrowing works on !== null, != null, typeof, in and truthiness checks, and on early returns.
  • Truthiness and || treat 0, "" and NaN as missing: count || 1 turns a real 0 into 1. Use ?? and != null for “missing”, and || only when empty values should also be replaced.

Swift

Refresher:The four tools

if let x = a unwraps for one block, guard let for the rest of the function. ?. reads through a missing value and stays nil, and ?? replaces it with a default.

Kotlin

Refresher:The four tools

if (x != null) smart-casts for one block, val x = a ?: return for the rest of the function. ?. reads through a missing value and stays null, and ?: replaces it with a default.

Java

Refresher:The four tools

if (x != null) for one block, if (x == null) return; for the rest of the method. Optional.map reads through a missing value, and orElse / orElseGet replace it with a default.

Check this part

  1. What does user?.address?.city give you when user is nil?What does user?.address?.city give you when user is null?user is null. What does Optional.ofNullable(user).map(User::address).map(Address::city) give you?

  2. nickname is nil. What is nickname ?? "friend"?nickname is null. What is nickname ?: "friend"?nickname is null. What is Optional.ofNullable(nickname).orElse("friend")?nickname is undefined. What is nickname ?? "friend"?

  3. What must the else branch of a guard let do?Why does Kotlin reject if (p.nickname != null) println(p.nickname.length) when nickname is a var property?For a local variable that may be null, what's the better choice in Java?count is a number | undefined, and 0 is a valid count. Which default is right?

  4. cached holds a value. Does cached ?? loadFromDisk() call loadFromDisk()?After val name = user.name ?: return, what is the type of name?user is null. What is Optional.ofNullable(user).map(User::city).orElse("Unknown")?

Unwrap with if let or a null check, exit early with guard let or ?: return, chain with ?. and fall back with ?? or ?:.

Part Not started

Force unwrapping: the escape hatch

Every language lets you say “trust me, it’s there”: ! in Swift, !! in Kotlin, and in Java simply using the reference (or Optional.get()). If you’re wrong, the app crashes on that line. TypeScript’s ! is the odd one out: it only silences the compiler and checks nothing at runtime, so the empty value keeps travelling.

A common place to get it wrong is a map lookup. Asking a map for a key it doesn’t have doesn’t crash and doesn’t give zero: it gives back an empty value, so the lookup’s type is optional (Double?).

Basics:What are lists, sets and maps?

Collections hold many values:

  • A list (Swift Array, Kotlin List, Java List) keeps items in order, and allows duplicates: ["Ana", "Ben", "Ana"].
  • A set holds each value at most once and answers “is this in it?” very quickly.
  • A map (Swift Dictionary, Kotlin and Java Map) stores values under keys: "Ana" → 30, looked up by the key.
Spot the bugCheckout total

Tap the line with the bug.

let prices: [String: Double] = ["apple": 0.5, "pear": 0.75]

func total(for cart: [String]) -> Double {
    var sum = 0.0
    for item in cart {
        sum += prices[item]!
    }
    return sum
}
val prices = mapOf("apple" to 0.5, "pear" to 0.75)

fun total(cart: List<String>): Double {
    var sum = 0.0
    for (item in cart) {
        sum += prices[item]!!
    }
    return sum
}
static final Map<String, Double> PRICES = Map.of("apple", 0.5, "pear", 0.75);

static double total(List<String> cart) {
    double sum = 0;
    for (String item : cart) {
        sum += PRICES.get(item);
    }
    return sum;
}
const prices = new Map([["apple", 0.5], ["pear", 0.75]]);

function total(cart: string[]): number {
  let sum = 0;
  for (const item of cart) {
    sum += prices.get(item)!;
  }
  return sum;
}

Force unwrapping is fine when a missing value means the program itself is wrong, not the data. Even then, crash with a message that explains the broken assumption:

guard let url = URL(string: "https://example.com/api") else {
    preconditionFailure("Hard-coded API URL is invalid")
}
val apiKey = requireNotNull(BuildConfig.API_KEY.takeIf { it.isNotBlank() }) { "API_KEY is missing from the build" }
String apiKey = Objects.requireNonNull(System.getenv("API_KEY"), "API_KEY environment variable is not set");
const apiKey = process.env.API_KEY;
if (!apiKey) throw new Error("API_KEY environment variable is not set");
SwiftKotlinJavaTypeScript
Crash with a messagepreconditionFailure("…")requireNotNull(a) { "…" }Objects.requireNonNull(a, "…")if (!a) throw new Error("…")
Forcea!a!!opt.orElseThrow()a! (unchecked)
  • Force only programmer errors (a hard-coded URL, a missing build constant), and crash with a message. Data from users and servers can always be missing.
  • A map lookup can find nothing. A missing price is a business decision, not a default of 0: say what it means (an “unknown product” error, for example).

Java

Java hides the force: sum += map.get(key) unboxes a null Double and throws NullPointerException.

TypeScript

TypeScript’s ! is erased at compile time and checks nothing: prices.get(item)! lets undefined through, and sum + undefined is NaN, not a crash. Throw explicitly when the value is required.

Check this part

  1. A price map has no "banana". What is prices["banana"]?A price map has no "banana". What is prices.get("banana")?A price Map has no "banana". What is prices.get("banana")?

  2. When is a force unwrap (!) a reasonable choice?When is a force unwrap (!!) a reasonable choice?When is it reasonable to let a missing value crash the program?When is a non-null assertion (!) reasonable?

  3. At checkout, an item in the cart has no price in the map. What's the right fix?

! and !! turn a missing value into a crash. Decide what missing means instead, and force only programmer errors, with a message.

Part Not started

Missing values in collections

Lists often contain gaps, for example users where only some have a middle name. Collect the middle names the obvious way, and the gaps come along:

let middleNames = users.map(\.middleName)      // [String?]
print(middleNames)   // [Optional("Petar"), nil, Optional("Ivan")]
val middleNames = users.map { it.middleName }   // List<String?>
println(middleNames.joinToString())   // Petar, null, Ivan
List<String> middleNames = users.stream().map(User::middleName).toList();
System.out.println(String.join(", ", middleNames));   // Petar, null, Ivan
const middleNames = users.map((u) => u.middleName);   // (string | null)[]
console.log(middleNames.join(", "));   // Petar, , Ivan

Every piece of code that uses this list now has to check each name again, or it prints “null” to your users. All three languages can drop the empty ones in one step instead:

let middleNames = users.compactMap(\.middleName)   // [String]
val middleNames = users.mapNotNull { it.middleName }   // List<String>
List<String> middleNames = users.stream()
    .map(User::middleName)
    .filter(Objects::nonNull)
    .toList();
const middleNames = users
  .map((u) => u.middleName)
  .filter((name) => name !== null);   // string[] (TypeScript 5.5+ infers it)

Notice the result type: a list of String, not of String?. The empty values are gone, so the rest of your code doesn’t need to check again.

Exerciseeasy

Print a shipping label

A user may not have an address yet, and an address may have no apartment number or postal code. Write shippingLabel(user) that returns one line per value that exists, or "Add an address to check out" when there’s no address at all.

struct Address {
    let street: String
    let apartment: String?
    let city: String
    let postalCode: String?
}

struct User {
    let name: String
    let address: Address?
}
data class Address(val street: String, val apartment: String?, val city: String, val postalCode: String?)
data class User(val name: String, val address: Address?)
record Address(String street, String apartment, String city, String postalCode) {}
record User(String name, Address address) {}
interface Address {
  street: string;
  apartment?: string;
  city: string;
  postalCode?: string;
}

interface User {
  name: string;
  address?: Address;
}

For Ana, living at Ilica 10, apartment 4, 10000 Zagreb, the label is:

Ana Horvat
Ilica 10
Apt 4
10000 Zagreb

Without an apartment and postal code, it’s just Ana Horvat, Ilica 10 and Zagreb.

›

Start with an early exit for the missing address. Below it, address is no longer optional.

›

Put all the lines in a list, including the missing ones, and then drop the missing ones in one step: compactMap in Swift, listOfNotNull in Kotlin, filter(Objects::nonNull) in Java, filter((line) => line !== undefined) in TypeScript.

SolutionTry it yourself first
func shippingLabel(for user: User) -> String {
    guard let address = user.address else {
        return "Add an address to check out"
    }
    let cityLine = [address.postalCode, address.city]
        .compactMap { $0 }
        .joined(separator: " ")
    let lines: [String?] = [user.name, address.street, address.apartment.map { "Apt \($0)" }, cityLine]
    return lines.compactMap { $0 }.joined(separator: "\n")
}
fun shippingLabel(user: User): String {
    val address = user.address ?: return "Add an address to check out"
    val cityLine = listOfNotNull(address.postalCode, address.city).joinToString(" ")
    return listOfNotNull(user.name, address.street, address.apartment?.let { "Apt $it" }, cityLine)
        .joinToString("\n")
}
static String shippingLabel(User user) {
    Address address = user.address();
    if (address == null) {
        return "Add an address to check out";
    }
    String apartment = address.apartment() == null ? null : "Apt " + address.apartment();
    String cityLine = Stream.of(address.postalCode(), address.city())
        .filter(Objects::nonNull)
        .collect(Collectors.joining(" "));
    return Stream.of(user.name(), address.street(), apartment, cityLine)
        .filter(Objects::nonNull)
        .collect(Collectors.joining("\n"));
}
function shippingLabel(user: User): string {
  const address = user.address;
  if (!address) {
    return "Add an address to check out";
  }
  const apartment = address.apartment === undefined ? undefined : `Apt ${address.apartment}`;
  const cityLine = [address.postalCode, address.city].filter((part) => part !== undefined).join(" ");
  return [user.name, address.street, apartment, cityLine]
    .filter((line) => line !== undefined)
    .join("\n");
}

Notice address.apartment.map { "Apt \($0)" } and apartment?.let { "Apt $it" }: they transform the value only if it exists, and stay empty otherwise. That’s the same idea as ?., for your own code instead of a property.

The result type is non-optional ([String], List<String>), so the filtering happens once, where the data comes in. Kotlin also has filterNotNull() for a list that is already List<T?>, and listOfNotNull(a, b, c) for building one.

“Not cached” vs “cached as missing”

Spot the bugA cache that never hits for deleted users

Tap the line with the bug.

private val cache = HashMap<String, User?>()

suspend fun user(id: String): User? {
    val cached = cache[id]
    if (cached != null) return cached
    val loaded = api.fetchUser(id)   // null when the user was deleted
    cache[id] = loaded
    return loaded
}
private final Map<String, User> cache = new HashMap<>();

User user(String id) {
    User cached = cache.get(id);
    if (cached != null) return cached;
    User loaded = api.fetchUser(id);   // null when the user was deleted
    cache.put(id, loaded);
    return loaded;
}
const cache = new Map<string, User | null>();

async function user(id: string): Promise<User | null> {
  const cached = cache.get(id);
  if (cached) return cached;
  const loaded = await api.fetchUser(id);   // null when the user was deleted
  cache.set(id, loaded);
  return loaded;
}

SwiftKotlinJavaTypeScript
Drop emptiescompactMapmapNotNull, filterNotNullfilter(Objects::nonNull)filter((x) => x != null)

Swift

  • The result is [String], not [String?]: filter once where the data comes in.
  • A lookup in [String: User?] returns User??: .none means “no such key”, .some(nil) means “stored as missing”, so a cache of missing users works.

Kotlin

  • The result is List<String>: filter once where the data comes in. listOfNotNull(a, b, c) builds such a list directly.
  • In a Map<String, User?>, map[id] == null means both “no such key” and “stored as missing”. Ask id in map, or store an explicit Found / NotFound result.

Java

  • The filtered list holds no nulls: filter once where the data comes in.
  • map.get(id) == null means both “no such key” and “stored as null”. Ask containsKey, or store an explicit result; computeIfAbsent never stores null at all.

TypeScript

  • filter((x) => x != null) is typed T[] from TypeScript 5.5 (inferred type predicates); before that, write (x): x is T => x != null.
  • join prints null and undefined as empty strings, so gaps show up as ", ,".
  • map.get(id) is undefined for a missing key and whatever you stored otherwise; ask map.has(id) when null or undefined can be stored.

Check this part

  1. middleName is a String?. What type is users.compactMap(\.middleName)?middleName is a String?. What type is users.mapNotNull { it.middleName }?What does users.stream().map(User::middleName).filter(Objects::nonNull).toList() contain?middleName is string | null. What type is users.map((u) => u.middleName).filter((n) => n !== null) (TypeScript 5.5+)?

  2. Which function drops the missing values while transforming a list?Which stream step drops the null values?You print ["Petar", null, "Ivan"].join(", "). What do your users see?

  3. What type does a lookup in a [String: User?] dictionary return?A HashMap<String, User?> returns null for cache[id]. What does that tell you?A Map<String, User> cache returns null for cache.get(id). What does that tell you?A Map<string, User | null> cache. cache.get(id) is falsy. What does that tell you?

Drop missing values in one step with compactMap, mapNotNull or filter(Objects::nonNull), and the result no longer needs checking.

Part Not started

Where null sneaks back in

Your User type says name is never missing, and for a year that’s true. Then the server team ships a change, and accounts created through a new sign-up flow come back without name. The app crashes on the profile screen, in code that “can’t” crash, three screens away from the network code that let the empty value in.

The compiler can only check code it can see. Values that come from outside your code can still be missing even when your type says they can’t: JSON from a server, a row from a database, a Java library called from Kotlin.

So check them once, where they enter your app, for example while decoding the server’s response. After that, the rest of your code can trust the types.

In Java, a method that may not find anything should say so in its return type with Optional<T>. Swift, Kotlin and TypeScript simply return an optional type:

func findUser(email: String) -> User? { … }

let name = findUser(email: typed)?.name ?? "Guest"
fun findUser(email: String): User? { … }

val name = findUser(typed)?.name ?: "Guest"
Optional<User> findUser(String email) { … }

String name = findUser(typed).map(User::name).orElse("Guest");
function findUser(email: string): User | undefined { … }

const name = findUser(typed)?.name ?? "Guest";

Use Optional only for return values like this one. For fields and parameters, a plain null check is simpler.

Null safety is only as strong as its weakest boundary. These are the places that bite in production.

Kotlin: platform types from Java

A Java method without annotations returns a platform typePlatform typeA type from Java whose nullability Kotlin doesn't know, shown as String! and not checked by the compiler.Open in glossary →, shown as String!. Kotlin lets you treat it as nullable or not, and checks nothing:

val name = javaUser.getDisplayName()   // String! (unknown)
println(name.length)                   // compiles; throws NullPointerException if Java returned null

Annotate the Java side with JSpecify (@NullMarked on the package or class, @Nullable where needed) and Kotlin sees real String and String? types. In mixed codebases, annotating the Java APIs that Kotlin calls most is the cheapest crash fix you’ll find.

Swift: implicitly unwrapped optionals and Objective-C

String! in Swift is an optional that unwraps itself on every use. It exists for values that are set after initialization, like @IBOutlet weak var titleLabel: UILabel!. Touch the outlet before the view has loaded and you get the same “unexpectedly found nil” crash. Objective-C APIs without nullable / nonnull annotations also import this way.

Java: Optional anti-patterns

  • Optional as a field or parameter. It isn’t serializable, adds an allocation, and a parameter can still be passed null. Use it for return values.
  • isPresent() then get(). That’s a null check with extra steps. Use map, orElse, orElseGet or ifPresent, and orElseThrow() instead of get().
  • Optional<List<T>>. Return an empty list instead.
  • Eager defaults. orElse(expensive()) always calls expensive(). See the puzzle below.

Most of these come from treating Optional like a nullable reference. Compare:

// A null check with extra steps
Optional<User> user = repository.findById(id);
if (user.isPresent()) {
    return user.get().name();
} else {
    return "Guest";
}

// What Optional is for
return repository.findById(id).map(User::name).orElse("Guest");

Predict the output

What does this print?

func loadFromDisk() -> String {
    print("Loading from disk")
    return "fresh"
}

let cached: String? = "cached"
let value = cached ?? loadFromDisk()
print(value)
fun loadFromDisk(): String {
    println("Loading from disk")
    return "fresh"
}

fun main() {
    val cached: String? = "cached"
    val value = cached ?: loadFromDisk()
    println(value)
}
static String loadFromDisk() {
    System.out.println("Loading from disk");
    return "fresh";
}

public static void main(String[] args) {
    String cached = "cached";
    String value = Optional.ofNullable(cached).orElse(loadFromDisk());
    System.out.println(value);
}
function loadFromDisk(): string {
  console.log("Loading from disk");
  return "fresh";
}

const cache = new Map([["user", "cached"]]);
const value = cache.get("user") ?? loadFromDisk();
console.log(value);

Non-null types meet JSON

The compiler’s guarantees end where reflection begins. A JSON decoder that creates objects without calling your constructor can put null into a property the type system says is never null:

Predict the output

The server sends {} (no name). What happens?

struct User: Decodable {
    let name: String
}

let user = try JSONDecoder().decode(User.self, from: Data("{}".utf8))
print(user.name.count)
data class User(val name: String)

val user = Gson().fromJson("{}", User::class.java)
println(user.name.length)
record User(String name) {}

User user = new ObjectMapper().readValue("{}", User.class);
System.out.println(user.name().length());
interface User {
  name: string;
}

const user: User = JSON.parse("{}");
console.log(user.name.length);
Exercisemedium

Make a Java module null-safe for Kotlin

A Kotlin app calls this Java repository, and crash reports show NullPointerException deep inside Kotlin code that never uses !!:

public interface UserRepository {
    User findById(String id);          // returns null when there's no such user
    List<User> findAll();              // never null, may be empty
    void save(User user);              // user must not be null
    String displayName(User user);     // null when the user has no name set
}

Make the nullability explicit so Kotlin sees real types, and make sure it stays correct as the Java code changes.

›

Kotlin sees every type here as a platform type (User!, (Mutable)List<User!>!). Which annotation library does the Kotlin compiler understand as strictly as its own types?

›

JSpecify lets you declare the default once (@NullMarked) and mark only the exceptions with @Nullable. Annotations are only as good as the code behind them, so think about a build-time check for the Java side too.

SolutionTry it yourself first
// package-info.java: everything in this package is non-null unless marked
@NullMarked
package com.example.users;

import org.jspecify.annotations.NullMarked;
public interface UserRepository {
    @Nullable User findById(String id);
    List<User> findAll();
    void save(User user);
    @Nullable String displayName(User user);
}

What Kotlin now sees (Kotlin 2.1+ treats JSpecify as errors, not warnings):

JavaKotlin
@Nullable User findById(String id)fun findById(id: String): User?
List<User> findAll()fun findAll(): List<User> (element type non-null too)
void save(User user)fun save(user: User): passing null is a compile error
@Nullable String displayName(User user)fun displayName(user: User): String?

Then keep the annotations honest:

  • Enforce them in Java with NullAway (an Error Prone plugin): it fails the Java build when a @NullMarked method returns null without @Nullable.
  • Start where the crashes are. Annotate the APIs Kotlin calls most first; @NullMarked per class is fine while migrating.
  • Generics: List<@Nullable User> and @Nullable List<User> mean different things, and JSpecify can express both.

Under the hood

final class Box {}

print(MemoryLayout<Int>.size)    // 8
print(MemoryLayout<Int?>.size)   // 9: one extra byte says "is there a value?"
print(MemoryLayout<Box?>.size)   // 8: nil is stored as a null pointer, no extra byte
// String? is the same JVM reference as String: no wrapper object.
// Int? is boxed as java.lang.Integer, because a primitive int can't be null.
fun greet(name: String) = "Hi, $name"
// For public functions the compiler adds a guard for Java callers, roughly:
// Intrinsics.checkNotNullParameter(name, "name")
// Optional is a real object: one allocation per call (the JIT can sometimes remove it).
Optional<String> found = repository.findName(id);

// Since Java 14, NullPointerException messages name the culprit:
// Cannot invoke "String.length()" because the return value of "User.middleName()" is null
// Types are erased: string | null exists only for the compiler.
// At runtime there are two empty values:
let notSet: string | undefined;     // undefined: never set, missing property or key
let cleared: string | null = null;  // null: deliberately empty, what JSON uses

// x == null matches both; x === null matches only null.
  • Swift: Optional is an ordinary enum, case none and case some(Wrapped). ?. is sugar for flatMap, and if let is pattern matching on .some. For class references, nil uses the null pointer, so optionals are free.
  • Kotlin: nullability exists only at compile time (plus those parameter guards). It costs nothing at runtime, except boxing for nullable primitives like Int? in hot loops.
  • Java: helpful NullPointerException messages (JEP 358) are on by default since Java 15. They don’t prevent crashes, but they turn “NPE on line 120” into a diagnosis.
  • TypeScript: null safety is strictNullChecks (part of strict); without it, null fits every type, like in Java. All checks are compile-time only, so the leaks are anything typed by assertion rather than proof: any (from JSON.parse, untyped libraries), as User, !, and indexing (array[10] is typed T, not T | undefined, unless you turn on noUncheckedIndexedAccess).

Deep dives

Kotlin

Deep divePlatform types: where Kotlin's null safety endsInterview favorite

A Java method without nullability annotations returns a platform type, shown in the IDE as String!: Kotlin doesn’t know whether it can be null, so it lets you treat it as String or String?. If you pick String and Java returns null, the program fails right at that assignment with a NullPointerException (Kotlin inserts a check there, so at least the error points to the boundary, not three calls later).

The fixes live at the boundary: declare the type explicitly (val name: String? = javaUser.name), and annotate Java code with nullability annotations Kotlin understands (JSpecify, androidx.annotation, JetBrains), which turn platform types into real String or String?.

Swift

Deep diveOptional is just an enumInterview favorite

String? is sugar for Optional<String>, an ordinary enum with two cases: .none (written nil) and .some(wrapped). if let, guard let, ?. and ?? are pattern matching on that enum, and ! is a switch that traps on .none.

For class references it costs nothing extra: Swift uses the invalid null pointer value to represent .none, so MemoryLayout<UIView?>.size equals MemoryLayout<UIView>.size. For types without such a spare value, like Int, an optional adds a byte for the case tag (padded to the type’s alignment in arrays).

Java

Deep diveWhy Optional shouldn't be a field or a parameterInterview favorite

Optional was designed as a return type for “there may be no result” (Brian Goetz, who led the design, has said so repeatedly). As a field or parameter it adds an allocation, isn’t Serializable, and can itself be null, so you end up with three states instead of two.

Use @Nullable (or JSpecify annotations) for fields and parameters, Optional for return values, and avoid optional.get(): orElseThrow() (Java 10) says the same thing honestly, and orElse, map and ifPresent usually remove the need altogether.

TypeScript

Deep divenull vs undefined, and what strictNullChecks changesInterview favorite

Without strictNullChecks, null and undefined can be assigned to every type, so string quietly means “string, or nothing”. With it (it’s part of strict), string excludes both and you write string | null or name?: string.

The two values mean slightly different things: undefined is “missing” (an absent property, an argument not passed), null is “deliberately empty”. JSON has no undefined, so JSON.stringify drops such properties; x == null matches both; and ?? only replaces null and undefined, while || also replaces 0, "" and false.

Swift

  • String! (implicitly unwrapped) unwraps on every use and crashes with “unexpectedly found nil”. Objective-C APIs without nullable / nonnull import that way.
  • Decodable checks every non-optional key and throws keyNotFound, so missing JSON fields fail at the boundary.

Kotlin

  • An unannotated Java method returns a platform type (String!) that Kotlin lets you treat as nullable or not, unchecked. JSpecify annotations on the Java side (@NullMarked, @Nullable) turn them into real types.
  • Gson builds objects without calling the constructor, so a non-null property can hold null and crash far from the network code. kotlinx.serialization and Moshi throw while decoding.

Java

  • Kotlin callers see your unannotated methods as platform types (String!) and check nothing. Annotate with JSpecify (@NullMarked on the package, @Nullable where needed) and enforce it with NullAway.
  • Jackson passes null to a record’s constructor for a missing field. Reject it there (a null check in a compact constructor) or with Bean Validation.
  • Optional is for return values only. Prefer map / orElseGet / orElseThrow over isPresent + get; orElse(x) evaluates x even when a value exists.

TypeScript

  • Types are erased, so the guarantee ends wherever a type is asserted instead of checked: any (from JSON.parse, fetch().json() and untyped libraries), as User, and !.
  • const user: User = JSON.parse(body) checks nothing: a missing field is undefined at runtime and crashes at the first use. Validate with a schema (Zod, Valibot) where the data enters.
  • array[i] is typed T, not T | undefined, unless noUncheckedIndexedAccess is on.

Validate data once where it enters the app; after that, the types can be trusted.

Kotlin, Java

Refresher:JSpecify

A small, standard set of nullness annotations for Java: @NullMarked makes a package or class non-null by default, @Nullable marks the exceptions. Kotlin reads them as real T / T? types, and NullAway can enforce them in the Java build.

Check this part

  1. A function looks up a user by email and may find nobody. What should it return?Where does Java's Optional fit best?

  2. Your type says name is never missing, but the server can send JSON without it. Where should you check?

  3. What happens when you use a String! (implicitly unwrapped) value that is nil?Kotlin calls an unannotated Java method that returns String. What type does Kotlin see?cached holds a value. Does Optional.ofNullable(cached).orElse(loadFromDisk()) call loadFromDisk()?What does const user: User = JSON.parse(body) check?

  4. JSONDecoder decodes {} into struct User: Decodable { let name: String }. What happens?Gson decodes {} into data class User(val name: String). What happens?Jackson reads {} into record User(String name). What happens?const user: User = JSON.parse("{}"), where User has name: string. What happens on user.name.length?

The compiler only checks your own code: validate data where it enters the app, use Java's Optional only for return values, and annotate Java for Kotlin.

Key takeaways

  • In Swift and Kotlin, ? in a type means “may be missing”, and the compiler makes you handle it. In Java, any reference may be null.
  • Handle missing values with unwrapping (if let, != null), early exits (guard let, ?: return), optional chaining (?.) and defaults (??, ?:, orElse).
  • Force unwrapping (!, !!, Optional.get) turns a missing value into a crash. Ask what should happen instead.
  • Map lookups return optional values: decide what a missing key means.
  • Null safety is a compile-time guarantee that leaks at the boundaries: Java platform types, Objective-C without nullability annotations, implicitly unwrapped optionals, JSON decoding. Annotate and validate there.
  • Smart casts need stable values: copy mutable properties into a local val or use ?.let.
  • Java Optional is for return values. Prefer map/orElseGet/orElseThrow over isPresent + get, and never use it for fields, parameters or collections.
  • Force unwrap only programmer errors, with a message: requireNotNull, preconditionFailure, Objects.requireNonNull.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.