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.
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 |
// 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"}
// 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 and mapKeys always return a new NonEmptyMap — the original is
unchanged. 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
Cross-type conversions bridge NonEmptyMap with NonEmptySet, NonEmptyList, and the
standard Java collections.
| Method | 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 |
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 |
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]
// 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));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 |
| Merging two registries — result is always non-empty | nem1.merge(nem2, mergeFunction) |
| Filtering entries — result may be empty | nem.filter(predicate) → 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]})