Definition
- joining(delimiter, prefix, suffix) – concatenates
Streaminto one string, inserting the delimiter between elements and wrapping the result with prefix and suffix. - joining(delimiter) – the two-argument form without prefix and suffix.
- joining() – the zero-argument form with no separator at all.
- Backed by StringJoiner – uses
StringJoinerandStringBuilderfor efficient O(n) single-pass concatenation. - Empty stream – returns just prefix + suffix (e.g., "[]"), not an empty string.
Priya at the National Bank of India wants one neat audit log line listing all approvers: "[Rajesh, Priya, Amit]". She needs commas between names and square brackets around the whole thing. The three-argument joining does all three jobs at once: the separator goes between names, the opening bracket goes at the start, and the closing bracket goes at the end.
Understanding the Concepts
What does this question ask?
The interviewer wants you to concatenate stream elements into a formatted string using Collectors.joining. This tests knowledge of the three joining overloads, the requirement for Stream, and efficient string building.
How do you build a bracketed comma-separated string of names?
package com.bank.hr;
import java.util.*;
import java.util.stream.Collectors;
public class JoiningDemo {
public static void main(String[] args) {
List<Employee> employees = Employee.getSampleEmployees();
String formatted = employees.stream()
.map(Employee::getName)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(formatted);
}
}
[Rajesh, Priya, Amit, Kavita, Suresh, Deepak, Neha, Vikram, Ananya, Meera]
The map(Employee::getName) step converts Stream to Stream. The joining collector inserts ", " between consecutive names and wraps everything in "[" and "]".
What are the three overloads of joining?
| Overload | Parameters | Example result |
|---|---|---|
| joining() | None | "RajeshPriyaAmit" |
| joining(delimiter) | ", " | "Rajesh, Priya, Amit" |
| joining(delimiter, prefix, suffix) | ", ", "[", "]" | "[Rajesh, Priya, Amit]" |
List<String> names = List.of("Rajesh", "Priya", "Amit");
// No separator
String noSep = names.stream().collect(Collectors.joining());
// "RajeshPriyaAmit"
// Just delimiter
String delim = names.stream().collect(Collectors.joining(", "));
// "Rajesh, Priya, Amit"
// Delimiter + prefix + suffix
String full = names.stream().collect(Collectors.joining(", ", "[", "]"));
// "[Rajesh, Priya, Amit]"
What happens with an empty stream?
String empty = List.<String>of().stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(empty); // "[]"
For an empty stream, joining returns just prefix + suffix. This produces a clean "[]" instead of a broken fragment.
How do you handle null elements?
// WRONG: null element throws NullPointerException
try {
String broken = Stream.of("Rajesh", null, "Priya")
.collect(Collectors.joining(", "));
} catch (NullPointerException e) {
System.out.println("Null throws NPE in joining");
}
// CORRECT: filter nulls out
String safe = Stream.of("Rajesh", null, "Priya")
.filter(Objects::nonNull)
.collect(Collectors.joining(", ", "[", "]"));
// "[Rajesh, Priya]"
// CORRECT: map nulls to placeholder
String placeholder = Stream.of("Rajesh", null, "Priya")
.map(n -> n != null ? n : "UNKNOWN")
.collect(Collectors.joining(", ", "[", "]"));
// "[Rajesh, UNKNOWN, Priya]"
Why is joining more efficient than reduce for string concatenation?
// WRONG: reduce with + creates a new String object at every step
String slow = names.stream()
.reduce("", (a, b) -> a + ", " + b);
// O(n^2) because each + creates a new String
// CORRECT: joining uses StringBuilder internally
String fast = names.stream()
.collect(Collectors.joining(", "));
// O(n) because StringBuilder appends in place
The reduce approach creates a new String at every concatenation step, giving O(n^2) time complexity. The joining collector uses StringJoiner backed by StringBuilder, giving O(n) time.
How do you use joining as a downstream collector?
// Group by department, then join names within each group
Map<String, String> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(
Employee::getName,
Collectors.joining(", ", "[", "]"))));
namesByDept.forEach((dept, nameStr) ->
System.out.println(dept + ": " + nameStr));
TRADING: [Rajesh, Priya, Meera]
RISK: [Amit, Kavita, Deepak]
OPERATIONS: [Suresh, Neha]
COMPLIANCE: [Vikram, Ananya]
What are common mistakes?
Mistake 1: forgetting to map to String before joining.
// WRONG: joining works on CharSequence, not Employee
String wrong = employees.stream()
.collect(Collectors.joining(", ")); // compile error!
// CORRECT: map to String first
String correct = employees.stream()
.map(Employee::getName)
.collect(Collectors.joining(", "));
Mistake 2: getting an extra leading separator with reduce.
// WRONG: reduce gives ", Rajesh, Priya, Amit" (extra leading comma)
String wrong = names.stream()
.reduce("", (a, b) -> a + ", " + b);
// CORRECT: joining handles the first element correctly
String correct = names.stream()
.collect(Collectors.joining(", "));
What are common interview follow-up questions?
- "How do you join with a different separator for the last element?" There is no built-in support; manually handle the last element or use a third-party library.
- "How do you join with line breaks?" Use
joining("\n")as the delimiter. - "How do you limit the joined string to N characters?" Collect first, then truncate with
substring(0, Math.min(n, result.length())).
Visual Overview
Stream<Employee>
│
│ .map(Employee::getName)
▼
Stream<String> ["Rajesh", "Priya", "Amit"]
│
│ .collect(joining(", ", "[", "]"))
▼
StringJoiner internally:
┌─────────────────────────────────────────┐
│ prefix: "[" │
│ add("Rajesh") → [Rajesh │
│ add("Priya") → [Rajesh, Priya │
│ add("Amit") → [Rajesh, Priya, Amit │
│ suffix: "]" │
│ result: "[Rajesh, Priya, Amit]" │
└─────────────────────────────────────────┘
Key Takeaway
Collectors.joining with three arguments produces a formatted string with a delimiter between elements and a prefix and suffix around the whole result. The expression joining(", ", "[", "]") applied to names Rajesh, Priya, Amit produces "[Rajesh, Priya, Amit]".
The stream must be Stream, so map to String first with map(Employee::getName). Joining is backed by StringJoiner and StringBuilder, making it O(n) for single-pass concatenation. The reduce alternative with + is O(n^2) because each concatenation creates a new String object.
For empty streams, joining returns just the prefix and suffix ("[]"), which is a clean representation. Null elements throw NullPointerException, so filter them out or map to a placeholder. Joining also works as a downstream collector inside groupingBy to produce formatted strings per group.
Remember one sentence: joining concatenates stream elements with a delimiter and optional prefix/suffix using StringJoiner for O(n) efficiency, and always map to String first.
---