You need a list of line items that checkout will only read. The reflex is new ArrayList<>(), a few add calls, and a hope that nobody downstream mutates it. That hope is not a type.
Java 9 gave the hope a constructor: List.of, Set.of, and Map.of build unmodifiable collections that reject nulls. Java 10 added copyOf for the same contract from an existing collection. This post is those factories. The Collections hub already named unmodifiable vs immutable and views vs copies — we use those words, we do not re-lecture them.
The lab domain is the same checkout records as the rest of this series:
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
The mutable default you did not mean
Building a catalog list the long way looks like this:
LineItem mug = new LineItem("SKU-MUG", 2, new BigDecimal("12.00"));
LineItem tea = new LineItem("SKU-TEA", 1, new BigDecimal("4.50"));
List<LineItem> items = new ArrayList<>();
items.add(mug);
items.add(tea);
Order order = new Order("o-100", "ada@example.com", items, new BigDecimal("28.50"), true);
items.add(new LineItem("SKU-HACK", 99, BigDecimal.ZERO));
order.items() still points at items. The extra line item is now on the order. The type said List. It did not say “frozen.”
The factory version does:
List<LineItem> items = List.of(mug, tea);
Order order = new Order("o-100", "ada@example.com", items, new BigDecimal("28.50"), true);
items.add(new LineItem("SKU-HACK", 99, BigDecimal.ZERO));
// UnsupportedOperationException
List.of is the unmodifiable list. add, remove, and set all throw UnsupportedOperationException. That is an optional operation on List — pick a mutable type when you genuinely need to add later.
List.of, Set.of, Map.of
Three factories, one contract: unmodifiable, no nulls, independent of whatever local variable you used to build the arguments.
List<LineItem> items = List.of(mug, tea);
Set<String> skus = Set.of("SKU-MUG", "SKU-TEA");
Map<String, Order> byId = Map.of("o-100", order);
| Factory | Allows duplicates | Encounter order | Nulls |
|---|---|---|---|
List.of(...) | yes | argument order | no — NullPointerException |
Set.of(...) | no — IllegalArgumentException | unspecified | no |
Map.of(k, v, ...) | no duplicate keys — IllegalArgumentException | unspecified | no keys or values |
List.of(mug, mug) is two elements. Set.of("SKU-MUG", "SKU-MUG") throws at construction. Duplicate keys in Map.of throw the same way — you do not silently keep the last value.
Set.of("SKU-MUG", "SKU-MUG");
// IllegalArgumentException: duplicate element: SKU-MUG
Map.of("o-100", order, "o-100", order);
// IllegalArgumentException: duplicate key: o-100
List.of(mug, null);
// NullPointerException
Set.of iteration order is not insertion order. It is not a LinkedHashSet. If a test asserts that "SKU-MUG" comes out first because you passed it first, the test is lying about the contract. Need encounter order and uniqueness? LinkedHashSet — or copy a List.of into one when you need mutability too.
Empty factories exist and are useful return values:
List<LineItem> none = List.of();
Set<String> noSkus = Set.of();
Map<String, Order> empty = Map.of();
They are unmodifiable empties. Callers cannot add to what you returned.
Map.of stops at ten pairs
Map.of has overloads from zero arguments up to ten key/value pairs. An eleventh pair does not compile against of. That is when Map.ofEntries and Map.entry exist:
Map<String, Integer> qtyBySku = Map.ofEntries(
Map.entry("SKU-MUG", 2),
Map.entry("SKU-TEA", 1),
Map.entry("SKU-BIN", 4)
// …as many entries as you need
);
Two pairs still prefer Map.of("SKU-MUG", 2, "SKU-TEA", 1). Reach for ofEntries when the pair count grows or when you already have Map.Entry values. Duplicate keys still throw. Null keys and null values still throw.
Map.entry(k, v) itself is an unmodifiable entry: setValue throws. It is the factory-shaped entry, not a HashMap node.
copyOf: copy, then freeze
List.copyOf, Set.copyOf, and Map.copyOf arrived in Java 10. They take an existing collection or map and return an unmodifiable copy that rejects nulls.
List<LineItem> incoming = new ArrayList<>();
incoming.add(mug);
incoming.add(tea);
List<LineItem> snapshot = List.copyOf(incoming);
incoming.clear();
System.out.println(snapshot.size()); // 2 — independent of incoming
snapshot.add(mug); // UnsupportedOperationException
Same shape for sets and maps:
Set<String> liveSkus = new HashSet<>();
liveSkus.add("SKU-MUG");
Set<String> frozenSkus = Set.copyOf(liveSkus);
Map<String, Order> live = new HashMap<>();
live.put("o-100", order);
Map<String, Order> frozen = Map.copyOf(live);
Set.copyOf collapses duplicates because the result is a Set. List.copyOf preserves list duplicates. Null elements, keys, or values throw NullPointerException at copy time, not later when someone iterates.
Note: If the argument is already a list (or set, or map) produced by List.of / copyOf, the JDK may skip the defensive copy and return the same instance. That is an implementation detail, not a license to mutate. A Collections.unmodifiableList wrapper is not that trusted type — copyOf still copies it, which is what you want when the backing ArrayList is still live. View vs copy stays on the hub.
List<LineItem> factory = List.of(mug, tea);
List<LineItem> again = List.copyOf(factory);
// again == factory may be true — both are already unmodifiable factory lists
Not a view of a live ArrayList
The older ceremony wrapped a list you still held:
List<LineItem> live = new ArrayList<>();
live.add(mug);
List<LineItem> wrapped = Collections.unmodifiableList(live);
live.add(tea);
System.out.println(wrapped.size()); // 2 — the wrapper is a VIEW
unmodifiableList refuses mutation through the wrapper. It does not freeze live. The hub definition of view is exactly this. Do not ship unmodifiableList(live) and keep mutating live as if callers received a snapshot.
List.of and List.copyOf are the snapshot-shaped APIs. Wrap with unmodifiableXxx when you must expose a live collection as read-only and you intend later writes through the original to show up — a rare, deliberate view. Prefer copyOf at API boundaries.
When you still need mutability
Factories are the wrong default the moment the next line is add. Copy into a mutable collection:
List<LineItem> packing = new ArrayList<>(List.of(mug, tea));
packing.add(new LineItem("SKU-NOTE", 1, BigDecimal.ZERO));
new ArrayList<>(List.of(...)) is the honest “start from these elements, then mutate.” new HashSet<>(Set.of(...)) and new HashMap<>(Map.of(...)) are the same idea. Do not fight UnsupportedOperationException with a try/catch. That exception is the type telling you to pick a different class.
A method that returns items for the caller to grow should return new ArrayList<>(...), not List.of. A method that returns a catalog nobody should edit should return List.of or List.copyOf:
List<LineItem> catalogFor(Order order) {
return List.copyOf(order.items());
}
List<LineItem> packingSlip(Order order) {
List<LineItem> slip = new ArrayList<>(order.items());
slip.add(new LineItem("SKU-NOTE", 1, BigDecimal.ZERO));
return slip;
}
catalogFor survives a later mutation of a mutable items list on the order. packingSlip is a working copy. The return type is List either way; the factory vs constructor is the contract.
Arrays.asList is a different family
Arrays.asList is not List.of with an older name. It is a fixed-size view of an array. Nulls are allowed. set writes through to the array. add and remove throw.
LineItem[] array = { mug, tea };
List<LineItem> asList = Arrays.asList(array);
asList.set(0, tea); // array[0] is now tea
asList.add(mug); // UnsupportedOperationException
Arrays.asList(mug, null); // allowed
List.of(mug, null); // NullPointerException
Use Arrays.asList when you already have an array and a fixed-size list view is the point — including set. Use List.of when you want an unmodifiable list that rejects nulls. The full asList contract, including the array backing, lives in Collections and Arrays.
Elements are not frozen
Unmodifiable is the collection. It is not a deep freeze. LineItem and Order are records in this series, so their components do not have setters. A mutable Order class inside List.of can still change fields after insert. The factory does not walk the graph.
List<StringBuilder> labels = List.of(new StringBuilder("SKU-MUG"));
labels.get(0).append("-X");
System.out.println(labels.get(0)); // SKU-MUG-X
True immutability also requires immutable elements. The hub said it; the factory does not buy it for you. Prefer records (see Java Records) for values you put in List.of.
When factories shipped
| Release | API |
|---|---|
| Java 9 | List.of, Set.of, Map.of, Map.ofEntries, Map.entry |
| Java 10 | List.copyOf, Set.copyOf, Map.copyOf |
| Today | Prefer these over wrapping a live ArrayList at an API boundary |
Use Java 9+ for of, Java 10+ for copyOf. Examples here assume a current JDK, same as the rest of the series.
Interview lens
Interviewers want the three-way list construction, null policy, and whether the result is a view.
| Question | Honest answer |
|---|---|
List.of vs Arrays.asList vs new ArrayList<>? | List.of: unmodifiable, no nulls, own storage. Arrays.asList: fixed-size array view, nulls allowed, set writes through. new ArrayList<>: mutable copy (or empty). |
copyOf vs unmodifiableList? | copyOf copies (then freezes). unmodifiableList is a view of the list you passed. Mutate the original and only the view changes. |
| Why do factories throw on null? | The contract rejects null elements, keys, and values at construction with NPE. |
| More than ten map pairs? | Map.of tops out at ten pairs. Use Map.ofEntries(Map.entry(...), ...). |
| Are the elements immutable? | No. Unmodifiable collection, same objects. Records help; a mutable element does not freeze. |
Does Set.of preserve insert order? | No. Unspecified. Not LinkedHashSet. |
Wrong answer: “List.of is just Arrays.asList.” One rejects nulls and set. The other is a fixed-size array view that allows both.
What to say out loud: factory lists throw UnsupportedOperationException on structural and set. copyOf on a List.of result may be the same instance. unmodifiableList never copied.
Cheat sheet
List.of / Set.of / Map.of Java 9 unmodifiable, no nulls
Map.ofEntries / Map.entry Java 9 more than 10 pairs
List.copyOf / Set.copyOf / Map.copyOf Java 10 copy, then freeze
Duplicates List: allowed
Set.of / Map.of keys: IllegalArgumentException
Nulls NPE at construction (of and copyOf)
Set.of order unspecified — not LinkedHashSet
copyOf(List.of(x)) may reuse the instance (trusted unmodifiable)
Need mutate new ArrayList<>(List.of(...))
Need snapshot List.copyOf(live) — not unmodifiableList(live)
Need array view Arrays.asList — different family, nulls + set
Elements still mutable unless the element type is
Do:
- Return
List.of/Map.copyOffrom APIs that should not be mutated. - Use
Map.ofEntriesonce you pass ten pairs. - Copy into
ArrayList/HashMap/HashSetwhen the next call isaddorput.
Don’t:
- Treat
List.ofasArrays.asList. - Wrap a live
ArrayListwithunmodifiableListand call it a snapshot. - Assert
Set.ofiteration order in a test. - Catch
UnsupportedOperationExceptioninstead of picking a mutable type.
Wrap-up
List.of, Set.of, and Map.of are how a current JDK spells “these elements, nobody adds.” They reject nulls, reject duplicate set elements and map keys, and throw on mutation. copyOf is the same contract from a collection you already have. Collections.unmodifiableList remains a view. Arrays.asList remains a fixed-size array wrapper. When the caller must grow the list, new ArrayList<>(List.of(...)) is the mutable copy — not a fight with UnsupportedOperationException.
The static methods that sort, wrap, and search those collections are next. They are not a fourth List type.