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

FactoryWhen 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 entries
NonEmptyMap<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 wins
NonEmptyMap<String, Integer> dedup = NonEmptyMap.of("alice", 10, Map.of("alice", 99, "bob", 20));
// {alice=10, bob=20} (alice=99 dropped)
// singleton — exactly one entry
NonEmptyMap<String, Integer> single = NonEmptyMap.singleton("alice", 10);
// {alice=10}
// fromMap — safe conversion from a plain Map; empty map returns None
Option<NonEmptyMap<String, Integer>> fromMap = NonEmptyMap.fromMap(someMap);
// Some({...}) or None

Accessing elements

NonEmptyMap<String, Integer> nem = NonEmptyMap.of("alice", 10, Map.of("bob", 20));
String headKey = nem.headKey(); // "alice" — always present, never null
Integer headValue = nem.headValue(); // 10 — always present, never null
int size = nem.size(); // 2 — always >= 1
// get — Option-based safe lookup
Option<Integer> found = nem.get("bob"); // Some(20)
Option<Integer> missing = nem.get("unknown"); // None
// containsKey
boolean hasAlice = nem.containsKey("alice"); // true
boolean 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 unchanged
NonEmptyMap<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 value
NonEmptyMap<String, String> annotated = nem.mapValuesWithKey((k, v) -> k + "=" + v);
// {alice="alice=10", bob="bob=20"}
// mapKeys — transform every key; values follow their original key
NonEmptyMap<String, Integer> upper = nem.mapKeys(String::toUpperCase);
// {ALICE=10, BOB=20}
// Colliding mapped keys: head key takes priority; conflicting tail entries are dropped
NonEmptyMap<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 empty
Option<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 key
Option<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 conflicts
NonEmptyMap<String, Integer> combined = scores1.merge(scores2, Integer::sum);
// {alice=10, bob=20, carol=20} (bob: 5+15=20)
// merge with last-write-wins for conflicts
NonEmptyMap<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 preserved
NonEmptyMap<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 / FactoryReturnsNotes
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 head
NonEmptySet<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 head
NonEmptyList<Integer> vals = nem.values();
// NonEmptyList[10, 20, 30]
vals.head(); // 10
vals.size(); // 3
// toMap() — unmodifiable java.util.Map for interop with standard APIs
Map<String, Integer> javaMap = nem.toMap();
// toNonEmptyList() — all entries as Map.Entry objects in insertion order
NonEmptyList<Map.Entry<String, Integer>> entries = nem.toNonEmptyList();
// [alice=10, bob=20, carol=30]
// toStream() — bridge to java.util.stream.Stream for pipeline operations
nem.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 Stream
record 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));
// None

Interoperability — 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);
// None

Interoperability — 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.

MethodInputReturns
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

ScenarioRecommendation
At least one entry required by contractNonEmptyMap<K, V>
Map may legitimately be emptyMap<K, V>
Reading from an external source that may be emptyNonEmptyMap.fromMap(map) → handle None
Building from a stream.collect(NonEmptyMap.collector(keyFn, valFn))
Merging two registries — result is always non-emptynem1.merge(nem2, mergeFunction)
Filtering entries — result may be emptynem.filter(predicate) → handle None
All values must be present before processingNonEmptyMap.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));
}
}
// Usage
NonEmptyMap<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]})