Repository: langchain4j/langchain4j (2026-07-21T12:28:42Z)

Issue:
[BUG] Utils.merge is not null-safe: NPE for 3+ args with null, and can return null
**Describe the bug**

`Utils.merge(...)` (both the `List` and `Map` overloads in `langchain4j-core`) handles `null` inputs inconsistently:

1. **3+ arguments with a `null` element throws `NullPointerException`**, even though the 2-argument form explicitly tolerates `null`. The `length == 2` fast path null-checks its inputs, but the general accumulation path does not:

   ```java
   // langchain4j-core .../internal/Utils.java
   public static <T> List<T> merge(List<T>... lists) {
       if (lists.length < 2) { throw ...; }
       if (lists.length == 2) {
           if (lists[0] == null || lists[0].isEmpty()) return lists[1];
           else if (lists[1] == null || lists[1].isEmpty()) return lists[0];
       }
       List<T> result = new ArrayList<>();
       for (List<T> list : lists) {
           result.addAll(list);   // NPE if any `list` is null (reachable only for 3+ args)
       }
       return result;
   }
   ```

2. **`merge` can return `null`.** In the 2-argument fast path, when the first list/map is empty (or null) and the second is `null`, the method returns the `null` second argument:

   ```java
   merge(List.of(), null);   // returns null
   merge((List<Integer>) null, null); // returns null
   ```

   A merge utility returning `null` is a latent `NullPointerException` for callers, and is inconsistent with the empty-collection results it returns in every other case.

**Expected behavior**

`merge(...)` should treat `null` inputs as empty uniformly (regardless of argument count) and never return `null` — always returning a merged collection.

**To Reproduce**

```java
Utils.merge(List.of(1), null, List.of(3)); // throws NullPointerException
Utils.merge(List.of(), null);              // returns null instead of an empty list
```

The same applies to the `Map` overload.

**Additional context**

The existing tests (`UtilsTest#test_merge_lists` / `#test_merge_maps`) only cover non-null inputs, so this behavior is currently unspecified and untested. Happy to open a PR that makes both overloads null-safe and adds positive/negative coverage.


Discussion:

Patch:
diff --git a/langchain4j-core/src/main/java/dev/langchain4j/internal/Utils.java b/langchain4j-core/src/main/java/dev/langchain4j/internal/Utils.java
index 78f15410792..9cecbe649bb 100644
--- a/langchain4j-core/src/main/java/dev/langchain4j/internal/Utils.java
+++ b/langchain4j-core/src/main/java/dev/langchain4j/internal/Utils.java
@@ -687,17 +687,11 @@ public static <T> List<T> merge(List<T>... lists) {
             throw new IllegalArgumentException("lists must have at least 2 elements");
         }
 
-        if (lists.length == 2) {
-            if (lists[0] == null || lists[0].isEmpty()) {
-                return lists[1];
-            } else if (lists[1] == null || lists[1].isEmpty()) {
-                return lists[0];
-            }
-        }
-
         List<T> result = new ArrayList<>();
         for (List<T> list : lists) {
-            result.addAll(list);
+            if (list != null) {
+                result.addAll(list);
+            }
         }
         return result;
     }
@@ -707,16 +701,11 @@ public static <K, V> Map<K, V> merge(Map<K, V>... maps) {
             throw new IllegalArgumentException("maps must have at least 2 elements");
         }
 
-        if (maps.length == 2) {
-            if (maps[0] == null || maps[0].isEmpty()) {
-                return maps[1];
-            } else if (maps[1] == null || maps[1].isEmpty()) {
-                return maps[0];
-            }
-        }
-
         Map<K, V> result = new HashMap<>();
         for (Map<K, V> map : maps) {
+            if (map == null) {
+                continue;
+            }
             for (Map.Entry<K, V> e : map.entrySet()) {
                 if (result.putIfAbsent(e.getKey(), e.getValue()) != null) {
                     throw new IllegalArgumentException("Duplicate key: " + e.getKey());


Test patch:
diff --git a/langchain4j-core/src/test/java/dev/langchain4j/internal/UtilsTest.java b/langchain4j-core/src/test/java/dev/langchain4j/internal/UtilsTest.java
index 42486a5fc24..162f6d71832 100644
--- a/langchain4j-core/src/test/java/dev/langchain4j/internal/UtilsTest.java
+++ b/langchain4j-core/src/test/java/dev/langchain4j/internal/UtilsTest.java
@@ -519,11 +519,26 @@ void test_merge_lists() {
         assertThat(merge(List.of(), List.of(2))).isEqualTo(List.of(2));
         assertThat(merge(List.of(), List.of())).isEqualTo(List.of());
 
+        // more than two lists
+        assertThat(merge(List.of(1), List.of(2), List.of(3))).isEqualTo(List.of(1, 2, 3));
+
         assertThatThrownBy(() -> merge(List.of()))
                 .isExactlyInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("at least 2 elements");
     }
 
+    @Test
+    void test_merge_lists_is_null_safe() {
+        // null lists are treated as empty, for any number of arguments
+        assertThat(merge(null, List.of(2))).isEqualTo(List.of(2));
+        assertThat(merge(List.of(1), null)).isEqualTo(List.of(1));
+        assertThat(merge(List.of(1), null, List.of(3))).isEqualTo(List.of(1, 3));
+
+        // never returns null, even when every list is null or empty
+        assertThat(merge((List<Integer>) null, null)).isNotNull().isEmpty();
+        assertThat(merge(List.of(), (List<Integer>) null)).isNotNull().isEmpty();
+    }
+
     @Test
     void test_merge_maps() {
         assertThat(merge(Map.of("one", 1), Map.of("two", 2))).isEqualTo(Map.of("one", 1, "two", 2));
@@ -531,6 +546,10 @@ void test_merge_maps() {
         assertThat(merge(Map.of(), Map.of("two", 2))).isEqualTo(Map.of("two", 2));
         assertThat(merge(Map.of(), Map.of())).isEqualTo(Map.of());
 
+        // more than two maps
+        assertThat(merge(Map.of("one", 1), Map.of("two", 2), Map.of("three", 3)))
+                .isEqualTo(Map.of("one", 1, "two", 2, "three", 3));
+
         assertThatThrownBy(() -> merge(Map.of()))
                 .isExactlyInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("at least 2 elements");
@@ -538,4 +557,16 @@ void test_merge_maps() {
                 .isExactlyInstanceOf(IllegalArgumentException.class)
                 .hasMessage("Duplicate key: one");
     }
+
+    @Test
+    void test_merge_maps_is_null_safe() {
+        // null maps are treated as empty, for any number of arguments
+        assertThat(merge(null, Map.of("two", 2))).isEqualTo(Map.of("two", 2));
+        assertThat(merge(Map.of("one", 1), null)).isEqualTo(Map.of("one", 1));
+        assertThat(merge(Map.of("one", 1), null, Map.of("three", 3))).isEqualTo(Map.of("one", 1, "three", 3));
+
+        // never returns null, even when every map is null or empty
+        assertThat(merge((Map<String, Integer>) null, null)).isNotNull().isEmpty();
+        assertThat(merge(Map.of(), (Map<String, Integer>) null)).isNotNull().isEmpty();
+    }
 }