Tuples

Runnable example: TupleSample.java

What are Tuples?

Tuple2<A,B>, Tuple3<A,B,C>, and Tuple4<A,B,C,D> are immutable, type-safe groupings of 2, 3, or 4 heterogeneous values without requiring a named class or record.

All three are Java record types, which means:

  • Structural equality (equals/hashCode) is derived automatically.
  • toString produces a readable representation with field names.
  • Fields are exposed via accessor methods _1(), _2(), _3(), _4() — consistent across all tuple arities.

Null policy: All three types are @NullMarked, but the enforcement point differs:

  • Tuple3 and Tuple4 use compact record constructors that call Objects.requireNonNull on every component. Both the of() factory and the raw record constructor (new Tuple3<>(...) / new Tuple4<>(...)) throw NullPointerException if any element is null.
  • Tuple2 validates only inside its of() factory. The raw record constructor (new Tuple2<>(...)) bypasses the check and accepts null elements. Prefer Tuple2.of(...) in application code.

Use Tuples when:

  • You need to return two or more values from a private or intermediate method without declaring a dedicated type.
  • You are building a stream pipeline and need to carry context (e.g., original value + computed result) through a chain of map steps.
  • The grouping is anonymous and local — it never appears in a public API or crosses a module boundary.

Creating instances

// Tuple2 — a pair (null-guarded factory method)
Tuple2<String, Integer> t2 = Tuple2.of("Alice", 30);
// Tuple3 — a triple (null-guarded factory method)
Tuple3<String, Integer, Boolean> t3 = Tuple3.of("Alice", 30, true);
// Tuple4 — a quadruple (null-guarded factory method)
Tuple4<String, Integer, Boolean, Double> t4 = Tuple4.of("Alice", 30, true, 1.75);
// of() methods and Tuple3/Tuple4 compact constructors use Objects.requireNonNull —
// null arguments throw NullPointerException at runtime
Tuple2.of("Alice", null); // throws NullPointerException (of() validates)
Tuple3.of("Alice", null, true); // throws NullPointerException (compact constructor validates)
new Tuple3<>("Alice", null, true); // also throws — compact constructor always runs

Accessing fields

Fields are accessed via zero-argument methods _1(), _2(), _3(), and _4().

Tuple2<String, Integer> t2 = Tuple2.of("Alice", 30);
String name = t2._1(); // "Alice"
int age = t2._2(); // 30
Tuple3<String, Integer, Boolean> t3 = Tuple3.of("Alice", 30, true);
String n2 = t3._1(); // "Alice"
int age3 = t3._2(); // 30
boolean active = t3._3(); // true
Tuple4<String, Integer, Boolean, Double> t4 = Tuple4.of("Alice", 30, true, 1.75);
double height = t4._4(); // 1.75

Transforming individual slots

All three types provide per-slot transformation methods. Each returns a new tuple with the targeted slot replaced; the remaining slots are carried through unchanged.

  • Tuple2mapFirst, mapSecond
  • Tuple3mapFirst, mapSecond, mapThird
  • Tuple4mapFirst, mapSecond, mapThird, mapFourth
// Tuple2 — transform individual slots; the other is unchanged
Tuple2<String, Integer> t2 = Tuple2.of("alice", 30);
Tuple2<String, Integer> upper = t2.mapFirst(String::toUpperCase); // ("ALICE", 30)
Tuple2<String, Integer> older = t2.mapSecond(age -> age + 1); // ("alice", 31)
// Tuple3 — transform individual slots; the others are unchanged
Tuple3<String, Integer, Boolean> t3 = Tuple3.of("alice", 30, true);
Tuple3<String, Integer, Boolean> upper3 = t3.mapFirst(String::toUpperCase); // ("ALICE", 30, true)
Tuple3<String, Integer, Boolean> older3 = t3.mapSecond(age -> age + 1); // ("alice", 31, true)
Tuple3<String, Integer, Boolean> toggled3 = t3.mapThird(b -> !b); // ("alice", 30, false)
// Tuple4 — additionally has mapFourth
Tuple4<String, Integer, Boolean, Double> t4 = Tuple4.of("alice", 30, true, 1.75);
Tuple4<String, Integer, Boolean, Double> taller = t4.mapFourth(h -> h + 0.1); // (..., 1.85)

Collapsing to a single value — map

Every tuple type can collapse all its elements into a single value:

  • Tuple2.map(BiFunction) — uses the standard java.util.function.BiFunction
  • Tuple3.map(TriFunction) — uses the library-provided TriFunction<A,B,C,R>
  • Tuple4.map(QuadFunction) — uses the library-provided QuadFunction<A,B,C,D,R>
// Tuple2.map — collapses two elements into one value via BiFunction
Tuple2<String, Integer> t2 = Tuple2.of("Alice", 30);
String label2 = t2.map((name, age) -> name + " (age " + age + ")");
// "Alice (age 30)"
// Tuple3.map — collapses three elements into one value via TriFunction
Tuple3<String, Integer, Boolean> t3 = Tuple3.of("Alice", 30, true);
String label3 = t3.map((name, age, active) ->
name + " (age " + age + ")" + (active ? " ✓" : ""));
// "Alice (age 30) ✓"
// Tuple4.map — collapses four elements into one value via QuadFunction
Tuple4<String, Integer, Boolean, Double> t4 = Tuple4.of("Alice", 30, true, 1.75);
String summary = t4.map((name, age, active, height) ->
name + " | " + age + "y | " + height + "m | " + (active ? "active" : "inactive"));
// "Alice | 30y | 1.75m | active"

Equality and toString

// Tuple types are records — equality, hashCode, and toString are derived automatically.
Tuple2<String, Integer> a = Tuple2.of("Alice", 30);
Tuple2<String, Integer> b = Tuple2.of("Alice", 30);
boolean same = a.equals(b); // true — structural equality
int hash = a.hashCode(); // consistent with equals
String repr = Tuple3.of("Alice", 30, true).toString();
// "Tuple3[_1=Alice, _2=30, _3=true]"

When to use Tuples vs Records

Scenario Recommendation
Anonymous intermediate value in a private method or pipeline Tuple2 / Tuple3 / Tuple4
Two values returned from a private helper Tuple2
Named, stable domain concept used in a public API Java record with named fields
Type appears in multiple modules or across service boundaries Java record with named fields
More than four elements Java record with named fields

The key rule: if you would name it, use a record. Tuples are for ephemeral, local groupings where the fields are obvious from context.

Real-world example

Using Tuple2 and Tuple3 to carry multiple values through a stream pipeline without declaring intermediate named types.

// Pair each user with their validation result inside a stream pipeline.
// Tuple2 keeps the original value and its outcome together without a named class.
List<Tuple2<User, ValidationResult>> results = users.stream()
.map(user -> Tuple2.of(user, validator.validate(user)))
.collect(Collectors.toList());
// Partition into valid and invalid
Map<Boolean, List<Tuple2<User, ValidationResult>>> partitioned = results.stream()
.collect(Collectors.partitioningBy(t -> t._2().isValid()));
List<Tuple2<User, ValidationResult>> valid = partitioned.get(true);
List<Tuple2<User, ValidationResult>> invalid = partitioned.get(false);
// Collapse each pair to a summary line using Tuple2.map(BiFunction)
List<String> report = invalid.stream()
.map(t -> t.map((user, res) ->
user.email() + ": " + String.join(", ", res.errors())))
.collect(Collectors.toList());
// Simpler with Tuple3: carry user, result, and timestamp together
Tuple3<User, ValidationResult, Instant> snapshot =
Tuple3.of(user, validator.validate(user), Instant.now());
String audit = snapshot.map((u, r, ts) ->
"[" + ts + "] " + u.email() + " → " + (r.isValid() ? "OK" : r.errors()));