NonEmptyMap<K, V>
Runnable example:
NonEmptyMapSample.java
What is NonEmptyMap<K, V>?
NonEmptyMap<K, V> is an immutable map guaranteed at construction time to contain
at least one entry. The non-emptiness constraint is encoded in the static type —
callers that receive a NonEmptyMap<K, V> never need to check whether it is empty.
Internal structure: a headKey + headValue (the first entry) and a tail
(an unmodifiable Map<K, V> for the remaining entries, which may be empty).
headKey() and headValue() are always present.
Null policy: NonEmptyMap is @NullMarked — null keys, values, or rest entries
throw NullPointerException at construction time.
Insertion order preserved: backed by LinkedHashMap, so iteration follows
insertion order with the head entry always first.
The design rationale — why a structural head+tail representation was chosen over runtime checks or existing JDK types — is documented in ADR-018 — NonEmptyList<T>, NonEmptySet<T>, NonEmptyMap<K,V> as structural guarantee types.
Use it when:
- An API contract requires at least one map entry and you want the compiler to enforce it.
- You are building a registry, configuration, or lookup table that must always have at least one entry.
- You want to eliminate runtime
isEmpty()guards on maps that are semantically never empty.
Creating instances
| Factory | When input is empty? |
|---|---|
NonEmptyMap.of(key, value, rest) | N/A — head entry is always required |
NonEmptyMap.singleton(key, value) | N/A — always one entry |
NonEmptyMap.fromMap(map) | Returns None |
NonEmptyMap.fromOptional(optional) | Returns None |
stream.collect(NonEmptyMap.collector(keyFn, valFn)) | Returns None |
// of(key, value, rest) — explicit head entry + additional entriesNonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 20, "carol", 30));// {alice=10, bob=20, carol=30}
// Duplicate head key in rest is silently ignored — head value winsNonEmptyMap<String, Integer> dedup = NonEmptyMap.of("alice", 10, Map.of("alice", 99, "bob", 20));// {alice=10, bob=20} (alice=99 dropped)
// singleton — exactly one entryNonEmptyMap<String, Integer> single = NonEmptyMap.singleton("alice", 10);// {alice=10}
// fromMap — safe conversion from a plain Map; empty map returns NoneOption<NonEmptyMap<String, Integer>> fromMap = NonEmptyMap.fromMap(someMap);// Some({...}) or NoneAccessing elements
NonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 20));
String headKey = nem.headKey(); // "alice" — always present, never nullInteger headValue = nem.headValue(); // 10 — always present, never nullint size = nem.size(); // 2 — always >= 1
// get — Option-based safe lookupOption<Integer> found = nem.get("bob"); // Some(20)Option<Integer> missing = nem.get("unknown"); // None
// containsKeyboolean hasAlice = nem.containsKey("alice"); // trueboolean hasDave = nem.containsKey("dave"); // false
// toMap — unmodifiable copy of all entries (not backed by internal storage)Map<String, Integer> all = nem.toMap(); // {alice=10, bob=20}size() always returns a value >= 1. headKey() and headValue() give direct,
null-safe access to the first entry. get(key) returns Option<V> — no nulls, no
NoSuchElementException.
Transformations
NonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 20));
// mapValues — transform every value; keys are unchangedNonEmptyMap<String, String> labeled = nem.mapValues(v -> v + " pts");// {alice="10 pts", bob="20 pts"}
// mapValuesWithKey — transform every value with access to both the key and valueNonEmptyMap<String, String> annotated = nem.mapValuesWithKey((k, v) -> k + "=" + v);// {alice="alice=10", bob="bob=20"}
// mapKeys — transform every key; values follow their original keyNonEmptyMap<String, Integer> upper = nem.mapKeys(String::toUpperCase);// {ALICE=10, BOB=20}
// Colliding mapped keys: head key takes priority; conflicting tail entries are droppedNonEmptyMap<String, Integer> collision = NonEmptyMap.of("a", 1, Map.of("A", 2));NonEmptyMap<String, Integer> mapped = collision.mapKeys(String::toUpperCase);// {A=1} (tail entry A=2 dropped because mapped key equals the new head key)mapValues, mapValuesWithKey, and mapKeys always return a new NonEmptyMap —
the original is unchanged. mapValuesWithKey is preferred over mapValues when the
mapped value needs the corresponding key. When mapKeys produces collisions, the head
key takes priority and conflicting tail entries are dropped.
Filter
NonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 5, "carol", 30));
// filter — predicate receives (key, value); returns Option because result may be emptyOption<NonEmptyMap<String, Integer>> highScorers = nem.filter((k, v) -> v >= 10);// Some({alice=10, carol=30})
Option<NonEmptyMap<String, Integer>> none = nem.filter((k, v) -> v > 100);// None
// filter on keyOption<NonEmptyMap<String, Integer>> startWithC = nem.filter((k, v) -> k.startsWith("c"));// Some({carol=30})filter returns Option<NonEmptyMap<K, V>> because filtering can produce an empty
result. The predicate receives both the key and the value via BiPredicate<K, V>.
Merge
NonEmptyMap<String, Integer> scores1 = NonEmptyMap.of("alice", 10, Map.of("bob", 5));NonEmptyMap<String, Integer> scores2 = NonEmptyMap.of("carol", 20, Map.of("bob", 15));
// merge — combine two maps; mergeFunction resolves conflictsNonEmptyMap<String, Integer> combined = scores1.merge(scores2, Integer::sum);// {alice=10, bob=20, carol=20} (bob: 5+15=20)
// merge with last-write-wins for conflictsNonEmptyMap<String, Integer> lastWins = scores1.merge(scores2, (v1, v2) -> v2);// {alice=10, bob=15, carol=20} (bob=15 from scores2)
// Disjoint maps — no conflict, all entries preservedNonEmptyMap<String, Integer> a = NonEmptyMap.singleton("x", 1);NonEmptyMap<String, Integer> b = NonEmptyMap.singleton("y", 2);NonEmptyMap<String, Integer> merged = a.merge(b, Integer::sum);// {x=1, y=2}merge takes a second NonEmptyMap and a BinaryOperator<V> to resolve key
conflicts. Because both inputs are non-empty, the result is guaranteed non-empty —
no Option wrapping needed.
Interoperability — Java standard types
Cross-type conversions bridge NonEmptyMap with NonEmptySet, NonEmptyList, and the
standard Java collections.
| Method / Factory | Returns | Notes |
|---|---|---|
keySet() | NonEmptySet<K> | All keys as a non-empty set; head key becomes set head |
values() | NonEmptyList<V> | All values in insertion order; duplicates preserved |
toMap() | Map<K, V> | Unmodifiable java.util.Map for standard API interop |
toStream() | Stream<Map.Entry<K,V>> | Bridge to Java stream pipelines; always at least one entry |
toNonEmptyList() | NonEmptyList<Map.Entry<K,V>> | All entries in insertion order |
fromMap(map) | Option<NonEmptyMap<K,V>> | Safe bridge from a plain Map; None if empty |
fromOptional(optional) | Option<NonEmptyMap<K,V>> | Bridge from Optional<Map<K,V>>; None if absent/empty |
collector(keyMapper, valueMapper) | Option<NonEmptyMap<K,V>> | Stream collector; None for an empty stream |
NonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 20, "carol", 30));
// keySet() — all keys as a NonEmptySet; head key becomes set headNonEmptySet<String> keys = nem.keySet();// NonEmptySet["alice", "bob", "carol"]keys.head(); // "alice"keys.size(); // 3
// values() — all values as a NonEmptyList in insertion order; head value becomes list headNonEmptyList<Integer> vals = nem.values();// NonEmptyList[10, 20, 30]vals.head(); // 10vals.size(); // 3
// toMap() — unmodifiable java.util.Map for interop with standard APIsMap<String, Integer> javaMap = nem.toMap();
// toNonEmptyList() — all entries as Map.Entry objects in insertion orderNonEmptyList<Map.Entry<String, Integer>> entries = nem.toNonEmptyList();// [alice=10, bob=20, carol=30]
// toStream() — bridge to java.util.stream.Stream for pipeline operationsnem.toStream() .filter(e -> e.getValue() >= 20) .forEach(e -> System.out.println(e.getKey() + " → " + e.getValue()));// bob → 20// carol → 30
// fromMap() — bridge from plain Map (e.g. from external source)Map<String, Integer> external = fetchScoresFromDatabase();Option<NonEmptyMap<String, Integer>> safe = NonEmptyMap.fromMap(external);safe.peek(scores -> processScores(scores));
// fromOptional() — bridge from Optional<Map<K,V>>Optional<Map<String, Integer>> maybeMap = Optional.of(Map.of("alice", 10));Option<NonEmptyMap<String, Integer>> fromOpt = NonEmptyMap.fromOptional(maybeMap);// Some({alice=10})
Option<NonEmptyMap<String, Integer>> fromEmptyOpt = NonEmptyMap.fromOptional(Optional.empty());// None
// collector(keyMapper, valueMapper) — build NonEmptyMap from a Streamrecord Employee(String name, int score) {}List<Employee> employees = List.of(new Employee("alice", 90), new Employee("bob", 75));
Option<NonEmptyMap<String, Integer>> scores = employees.stream() .collect(NonEmptyMap.collector(Employee::name, Employee::score));// Some({alice=90, bob=75})
Option<NonEmptyMap<String, Integer>> emptyResult = Stream.<Employee>empty() .collect(NonEmptyMap.collector(Employee::name, Employee::score));// NoneInteroperability — Option
sequenceOption converts a NonEmptyMap<K, Option<V>> into an
Option<NonEmptyMap<K, V>>. It is fail-fast in inspection: it stops iterating after
the first None.
// sequenceOption: NonEmptyMap<K, Option<V>> → Option<NonEmptyMap<K, V>>// Some if every value is Some; None as soon as any value is None (fail-fast in inspection).NonEmptyMap<String, Option<Integer>> allPresent = NonEmptyMap.of( "alice", Option.some(10), Map.of("bob", Option.some(20)));
Option<NonEmptyMap<String, Integer>> result = NonEmptyMap.sequenceOption(allPresent);// Some({alice=10, bob=20})
NonEmptyMap<String, Option<Integer>> withAbsent = NonEmptyMap.of( "alice", Option.some(10), Map.of("bob", Option.none()));
Option<NonEmptyMap<String, Integer>> missing = NonEmptyMap.sequenceOption(withAbsent);// NoneInteroperability — Try, Either, Result
sequenceTry, sequenceEither, and sequenceResult each convert a
NonEmptyMap of wrapped values into a single wrapped NonEmptyMap.
All three are fail-fast in inspection: they stop iterating after the first
failure/left/err.
| Method | Input | Returns |
|---|---|---|
NonEmptyMap.sequenceOption(nem) | NonEmptyMap<K, Option<V>> | Option<NonEmptyMap<K, V>> |
NonEmptyMap.sequenceTry(nem) | NonEmptyMap<K, Try<V>> | Try<NonEmptyMap<K, V>> |
NonEmptyMap.sequenceEither(nem) | NonEmptyMap<K, Either<E, V>> | Either<E, NonEmptyMap<K, V>> |
NonEmptyMap.sequenceResult(nem) | NonEmptyMap<K, Result<V, E>> | Result<NonEmptyMap<K, V>, E> |
// sequenceTry: NonEmptyMap<K, Try<V>> → Try<NonEmptyMap<K, V>>// Success if every value succeeds; Failure from the first failing entry.NonEmptyMap<String, Try<Integer>> tries = NonEmptyMap.of( "alice", Try.success(10), Map.of("bob", Try.success(20)));
Try<NonEmptyMap<String, Integer>> allOk = NonEmptyMap.sequenceTry(tries);// Success({alice=10, bob=20})
NonEmptyMap<String, Try<Integer>> withFailure = NonEmptyMap.of( "alice", Try.failure(new RuntimeException("boom")), Map.of("bob", Try.success(20)));
Try<NonEmptyMap<String, Integer>> failed = NonEmptyMap.sequenceTry(withFailure);// Failure(RuntimeException("boom"))
// sequenceEither: NonEmptyMap<K, Either<E, V>> → Either<E, NonEmptyMap<K, V>>// right if every value is right; left from the first left entry.NonEmptyMap<String, Either<String, Integer>> eithers = NonEmptyMap.of( "alice", Either.right(10), Map.of("bob", Either.right(20)));
Either<String, NonEmptyMap<String, Integer>> allRight = NonEmptyMap.sequenceEither(eithers);// right({alice=10, bob=20})
NonEmptyMap<String, Either<String, Integer>> withLeft = NonEmptyMap.of( "alice", Either.left("invalid"), Map.of("bob", Either.right(20)));
Either<String, NonEmptyMap<String, Integer>> firstLeft = NonEmptyMap.sequenceEither(withLeft);// left("invalid")
// sequenceResult: NonEmptyMap<K, Result<V, E>> → Result<NonEmptyMap<K, V>, E>// ok if every value is ok; err from the first error entry.NonEmptyMap<String, Result<Integer, String>> results = NonEmptyMap.of( "alice", Result.ok(10), Map.of("bob", Result.ok(20)));
Result<NonEmptyMap<String, Integer>, String> allOkResult = NonEmptyMap.sequenceResult(results);// ok({alice=10, bob=20})
NonEmptyMap<String, Result<Integer, String>> withErr = NonEmptyMap.of( "alice", Result.err("not found"), Map.of("bob", Result.ok(20)));
Result<NonEmptyMap<String, Integer>, String> firstErr = NonEmptyMap.sequenceResult(withErr);// err("not found")equals, hashCode, and toString
Equality is structural and consistent with Map: two NonEmptyMap instances are
equal if and only if their entry sets are equal (key equality + value equality, order
ignored). hashCode delegates to toMap().hashCode(). toString prints in
{key=value, ...} format.
When to use NonEmptyMap vs plain Map
| Scenario | Recommendation |
|---|---|
| At least one entry required by contract | NonEmptyMap<K, V> |
| Map may legitimately be empty | Map<K, V> |
| Reading from an external source that may be empty | NonEmptyMap.fromMap(map) → handle None |
| Building from a stream | .collect(NonEmptyMap.collector(keyFn, valFn)) |
| Merging two registries — result is always non-empty | nem1.merge(nem2, mergeFunction) |
| Filtering entries — result may be empty | nem.filter(predicate) → handle None |
| All values must be present before processing | NonEmptyMap.sequenceOption(nem) → handle None |
Real-world example
A role-to-permissions registry initialized with at least one role. The type system prevents constructing the service with an empty permissions map.
// A role-to-permissions registry that is always initialized with at least one entry.// The compiler prevents calling this service with an empty map.
public class RolePermissionsService {
private final NonEmptyMap<String, Set<String>> permissions;
public RolePermissionsService(NonEmptyMap<String, Set<String>> permissions) { this.permissions = permissions; }
/** Returns the permissions for a role, or an empty set if the role is unknown. */ public Set<String> permissionsFor(String role) { return permissions.get(role) .getOrElse(Set.of()); }
/** Returns a new service with an additional role merged in. */ public RolePermissionsService withRole(String role, Set<String> perms) { NonEmptyMap<String, Set<String>> updated = permissions.merge(NonEmptyMap.singleton(role, perms), (existing, incoming) -> { Set<String> merged = new HashSet<>(existing); merged.addAll(incoming); return Set.copyOf(merged); }); return new RolePermissionsService(updated); }
/** Returns only roles that have the given permission. */ public Option<NonEmptyMap<String, Set<String>>> rolesWithPermission(String permission) { return permissions.filter((role, perms) -> perms.contains(permission)); }}
// UsageNonEmptyMap<String, Set<String>> initial = NonEmptyMap.of( "admin", Set.of("read", "write", "delete"), Map.of( "editor", Set.of("read", "write"), "viewer", Set.of("read") ));
RolePermissionsService svc = new RolePermissionsService(initial);
Set<String> editorPerms = svc.permissionsFor("editor"); // [read, write]
Option<NonEmptyMap<String, Set<String>>> canWrite = svc.rolesWithPermission("write");// Some({admin=[read,write,delete], editor=[read,write]})