🌊 Streams API · Intermediate

Stream.toList vs collect(toList()) in Java

Unmodifiable vs no guarantee.

🧩 The mysteryYou "modernise" collect(Collectors.toList()) into the shorter .toList(). Everything compiles. Then a month later, production throws UnsupportedOperationException. What changed?

Three ways to end with a list

**stream.toList() (Java 16): unmodifiable**, allows null elements. **collect(Collectors.toList()): no guarantee about type or mutability. collect(Collectors.toUnmodifiableList()): unmodifiable, rejects null**.

🔮 Predict it

Changing a toList() result

What happens?

List<Integer> a = Stream.of(1, 2).toList();
a.set(0, 9);
System.out.println(a);
  1. [9, 2]
  2. Compile error
  3. Throws UnsupportedOperationException
Show the answer

It compiles — List has set — but the list from toList() is unmodifiable, so set (or add, remove) throws UnsupportedOperationException at runtime.

Works today, promised never

Collectors.toList() currently returns an **ArrayList**, so add happens to work. But its spec makes no promise about mutability — relying on it is relying on an implementation detail.

Needing a list you can change

✗ Unmodifiable
List<String> names = users.stream()
    .map(User::name)
    .toList();
names.add("admin"); // throws

toUnmodifiableList() and List.copyOf(...) are unmodifiable too.

✓ Guaranteed ArrayList
List<String> names = users.stream()
    .map(User::name)
    .collect(Collectors.toCollection(
        ArrayList::new));
names.add("admin"); // fine

toCollection(ArrayList::new) explicitly promises a mutable ArrayList.

⚠️ The trap

The null difference

Both are "unmodifiable", but they disagree about null: **Stream.toList() accepts nulls, while Collectors.toUnmodifiableList() throws NullPointerException**.

Stream.of("a", null).toList();  // [a, null]
Stream.of("a", null).collect(
    Collectors.toUnmodifiableList()); // NPE
💼 In the real world

The refactoring trap

IDEs and linters may suggest replacing collect(Collectors.toList()) with toList(). It's usually a good change — unless some caller later adds to or sorts the list in place. Then the bug hides until that code path runs in production.

Key takeaways

  1. stream.toList(): unmodifiable, allows null elements
  2. Collectors.toList(): no guarantee of type or mutability
  3. Collectors.toUnmodifiableList(): unmodifiable, rejects null
  4. Need mutability? collect(toCollection(ArrayList::new))
🤯 Did you know?

Stream.toList() was one of the most requested additions to the Streams API: it took until Java 16 — seven years after streams arrived in Java 8.

Practice questions

What does this print?

List<String> a = Stream.of("x").toList();
a.add("y");
System.out.println(a);
  1. [x, y]
  2. [x]
  3. Throws UnsupportedOperationException
  4. Compile error
Check your answer

Throws UnsupportedOperationException. The code compiles because List has add(), but the list returned by toList() is unmodifiable, so add throws at runtime.

What does this print?

List<String> b = Stream.of("x")
    .collect(Collectors.toList());
b.add("y");
System.out.println(b);
  1. [x, y]
  2. [x]
  3. Throws UnsupportedOperationException
  4. [y]
Check your answer

[x, y]. Today Collectors.toList() happens to return an ArrayList, so add works. But the spec makes no guarantee — use toCollection(ArrayList::new) when you rely on mutability.

Next: Java 24's gatherers — custom intermediate operations that can group neighbours into windows.