Definition
- toUnmodifiableList() – (Java 10+) collects into a genuinely immutable list with no backing mutable reference.
- collectingAndThen(toList(), Collections::unmodifiableList) – the Java 8 form that collects into a mutable ArrayList, then wraps it in an unmodifiable view.
- Stream.toList() – (Java 16+) the shortest form:
stream.toList()returns an unmodifiable list. - Null rejection –
toUnmodifiableList()throwsNullPointerExceptionif any element is null, consistent withList.of(). - Defensive design – returning unmodifiable lists from service methods prevents callers from mutating shared internal state.
Priya at the National Bank of India produces a finalized list of approved high-value account holders. She laminates the printout before distributing it. The laminated list is the unmodifiable collection: everyone can read it, but no one can add or remove a name. The toUnmodifiableList() method is the lamination step.
Understanding the Concepts
What does this question ask?
The interviewer wants you to produce an immutable list from a stream. This tests knowledge of Collectors.toUnmodifiableList, collectingAndThen, Stream.toList, and the differences between them.
How do you collect into an unmodifiable list with Java 10+?
package com.bank.hr;
import java.util.*;
import java.util.stream.Collectors;
public class UnmodifiableListDemo {
public static void main(String[] args) {
List<Employee> employees = Employee.getSampleEmployees();
// Java 10+: toUnmodifiableList()
List<String> names = employees.stream()
.map(Employee::getName)
.collect(Collectors.toUnmodifiableList());
System.out.println("Names: " + names);
// This throws UnsupportedOperationException:
try {
names.add("Rajesh");
} catch (UnsupportedOperationException e) {
System.out.println("Cannot modify: " + e.getClass().getSimpleName());
}
}
}
Names: [Rajesh, Priya, Amit, Kavita, Suresh, Deepak, Neha, Vikram, Ananya, Meera]
Cannot modify: UnsupportedOperationException
How do you do it in Java 8 with collectingAndThen?
// Java 8 portable form
List<String> namesJava8 = employees.stream()
.map(Employee::getName)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
// Same result: add/remove/set/clear all throw UnsupportedOperationException
collectingAndThen first collects into a mutable ArrayList, then applies the finisher function Collections::unmodifiableList which wraps it in a read-only view.
How does Stream.toList() work (Java 16+)?
// Java 16+: shortest form
List<String> namesJava16 = employees.stream()
.map(Employee::getName)
.toList(); // returns unmodifiable list
// Equivalent to collect(Collectors.toUnmodifiableList()) in behavior
How are the three approaches different?
| Feature | toUnmodifiableList() | collectingAndThen | Stream.toList() |
|---|---|---|---|
| Java version | 10+ | 8+ | 16+ |
| Null elements | Throws NPE | Allows nulls | Allows nulls |
| Backing reference | None (truly immutable) | Wrapper over ArrayList | None (truly immutable) |
| Conciseness | Medium | Verbose | Most concise |
| Type | Unmodifiable List | Unmodifiable view | Unmodifiable List |
// toUnmodifiableList: rejects null
try {
List<String> withNull = Stream.of("Rajesh", null, "Priya")
.collect(Collectors.toUnmodifiableList()); // NPE!
} catch (NullPointerException e) {
System.out.println("Null rejected by toUnmodifiableList");
}
// Stream.toList: allows null
List<String> withNullOk = Stream.of("Rajesh", null, "Priya")
.toList(); // [Rajesh, null, Priya] - works
What are common mistakes?
Mistake 1: assuming Collectors.toList() returns an unmodifiable list.
// WRONG assumption: toList() returns a MUTABLE ArrayList
List<String> mutable = employees.stream()
.map(Employee::getName)
.collect(Collectors.toList());
mutable.add("NewName"); // works! list is mutable
// CORRECT: use toUnmodifiableList() for immutability
List<String> immutable = employees.stream()
.map(Employee::getName)
.collect(Collectors.toUnmodifiableList());
immutable.add("NewName"); // UnsupportedOperationException
Mistake 2: confusing Stream.toList() (Java 16) with Collectors.toList() (Java 8).
// Stream.toList() -> unmodifiable
List<String> unmod = employees.stream().map(Employee::getName).toList();
// Collectors.toList() -> mutable
List<String> mod = employees.stream().map(Employee::getName)
.collect(Collectors.toList());
// They look similar but have different mutability guarantees!
When should you use unmodifiable lists?
- Return values from service methods: prevents callers from corrupting shared state.
- Configuration lists: loaded once, read many times, should never change.
- Audit trails: once recorded, entries must not be added or removed.
- Thread safety: an unmodifiable list is safe to share across threads without synchronization.
package com.bank.service;
import java.util.*;
import java.util.stream.Collectors;
public class ComplianceService {
private final List<Employee> employees;
public ComplianceService(List<Employee> employees) {
this.employees = List.copyOf(employees); // defensive copy
}
// Returns unmodifiable list: callers cannot corrupt internal state
public List<Employee> getHighValueAccountHolders() {
return employees.stream()
.filter(e -> e.getSalary().doubleValue() > 1_000_000)
.collect(Collectors.toUnmodifiableList());
}
}
What are common interview follow-up questions?
- "What is the difference between unmodifiable and immutable?" An unmodifiable view can still change if the backing collection changes. A truly immutable list (from
toUnmodifiableListorList.of) has no backing reference. - "How do you create an unmodifiable Set or Map?" Use
toUnmodifiableSet()ortoUnmodifiableMap(). - "What if you need to modify the list later?" Collect with
Collectors.toList()to get a mutable ArrayList.
Visual Overview
Stream<Employee>
│
│ .map(Employee::getName)
▼
Stream<String>
│
│ .collect(toUnmodifiableList())
▼
┌─────────────────────────────────────────┐
│ Unmodifiable List<String> │
│ [Rajesh, Priya, Amit, Kavita, ...] │
│ │
│ .add(x) → UnsupportedOperationEx │
│ .remove(x) → UnsupportedOperationEx │
│ .set(i, x) → UnsupportedOperationEx │
│ .clear() → UnsupportedOperationEx │
│ .get(i) → OK (read allowed) │
│ .size() → OK (read allowed) │
└─────────────────────────────────────────┘
Key Takeaway
Collecting a stream into an unmodifiable list prevents callers from adding, removing, or replacing elements after collection. On Java 10+, Collectors.toUnmodifiableList() produces a truly immutable list with no backing reference. On Java 8, collectingAndThen(toList(), Collections::unmodifiableList) wraps a mutable ArrayList in a read-only view. On Java 16+, Stream.toList() is the most concise option.
The key distinction is that toUnmodifiableList() rejects null elements (throws NullPointerException), while Stream.toList() and the collectingAndThen form allow nulls. The common mistake is assuming Collectors.toList() returns an unmodifiable list. It returns a mutable ArrayList.
In banking systems, returning unmodifiable lists from service methods is a defensive design practice. The compliance list of high-value account holders flows through audit, notification, and reporting components. Making it unmodifiable prevents any downstream component from accidentally adding or removing entries.
Remember one sentence: toUnmodifiableList makes the collected result read-only and null-free, while Stream.toList (Java 16+) is shorter but allows nulls.
---