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

ScenarioRecommendation
Anonymous intermediate value in a private method or pipelineTuple2 / Tuple3 / Tuple4
Two values returned from a private helperTuple2
Named, stable domain concept used in a public APIJava record with named fields
Type appears in multiple modules or across service boundariesJava record with named fields
More than four elementsJava 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()));