NonEmptyList<T>, NonEmptySet<T>, NonEmptyMap<K,V> as structural guarantee types
Context
Many domain operations require collections with at least one element: validation
errors, grouped results, non-empty inputs to functions. The standard JDK types
List<T>, Set<T>, and Map<K,V> allow empty instances and carry no non-emptiness
invariant in the type system, so the check is scattered across every caller.
Decision
Provide NonEmptyList<T>, NonEmptySet<T>, and NonEmptyMap<K,V> as dedicated
types that encode the non-emptiness constraint in the static type. Each type
stores a guaranteed head element plus an optionally-empty tail
(an unmodifiable sub-collection), making it structurally impossible to construct an
instance without at least one element. There is no emptiness check at a single
entry point; the constraint is a consequence of the representation.
Smart constructors that accept potentially empty sources (fromList, fromSet,
fromMap) return Option<NonEmptyX> rather than throwing, shifting the emptiness
handling to the type system — callers must handle the None case explicitly.
The three types choose their JDK interface based on what can be honoured without violating the invariant or the interface contract:
NonEmptyList<T>implementsjava.util.SequencedCollection<T>(Java 21+), notList<T>. This providesgetFirst(),getLast(), andreversed()as total functions (they never throwNoSuchElementException), while all mutating methods throwUnsupportedOperationException. ImplementingList<T>would exposeremove(int)andclear(), which cannot be implemented without breaking the non-emptiness invariant.NonEmptySet<T>implementsIterable<T>only. Backed byLinkedHashSet, insertion order is preserved.toSet()provides an unmodifiablejava.util.Setfor standard API interop. ImplementingSet<T>would exposeremoveandclear.NonEmptyMap<K,V>is a standalonefinalclass. Backed byLinkedHashMap, insertion order is preserved.toMap()provides an unmodifiablejava.util.Map. ImplementingMap<K,V>would exposeput,remove, andclear.
Validated and Guard use NonEmptyList<String> for error accumulation:
a Validated.Invalid always carries at least one error, and Guard always returns
at least one message when a check fails. This eliminates defensive isEmpty() checks
in callers.
Result.groupingBy returns Map<K, NonEmptyList<V>> rather than
Map<K, List<V>> — every group produced by grouping always contains at least one
element by construction. This decision is documented in
ADR-017.
Consequences
Positive:
- The invariant is enforced once at construction, not scattered across callers.
- Method signatures that require non-empty collections express it in the type
(
NonEmptyList<E>vsList<E>); callers never need to guard against empty instances. NonEmptyList.head(),getFirst(), andgetLast()are total functions — they never throw.- Validation pipelines using
Validated<NonEmptyList<E>, A>are guaranteed to carry at least one error in theInvalidcase, removing the need for defensiveisEmpty()checks on error lists. - Consistent with the library’s philosophy of making illegal states unrepresentable.
Negative / tradeoffs:
- Three additional public types; callers must convert to JDK collections at API
boundaries via
toList(),toSet(), ortoMap(). NonEmptyList<T>does not implementList<T>, so it cannot be passed to APIs that expectList<T>without conversion. It does implementSequencedCollection<T>(and transitivelyCollection<T>andIterable<T>), which satisfies many use sites.fromList/fromSet/fromMapreturnOption<NonEmptyX>, requiring callers to handle theNonecase when bridging from standard JDK types.
Alternatives considered
- Runtime assertion in callers: enforces the invariant only where remembered; fails late and is not visible at API boundaries.
- Implementing
List<T>/Set<T>/Map<K,V>directly: would expose mutating methods (add,remove,clear) that can only respond withUnsupportedOperationException, violating the interface contract and giving callers a false sense of compatibility. - Vavr’s
NonEmptyList: external dependency; heavier than needed for this focused use case. - Guava
ImmutableListwith a size check: doesn’t encode non-emptiness in the type; still requires a runtime check at every call site.