DEV Community

Cover image for Surfing with FP Java - Mastering UnaryOperator<T>
André Borba
André Borba

Posted on

Surfing with FP Java - Mastering UnaryOperator<T>

Introduction

In the last episode, we mastered Supplier, the source that generates values from nothing.

Now, let's turn to UnaryOperator, a specialization of Function built for one very specific job: transforming a value into another value of the exact same type.

If Predicate decides, Function transforms, Consumer acts, and Supplier provides, then UnaryOperator refines. It takes something, and gives back more of the same kind, changed.

What Is UnaryOperator?

Here's the definition:

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {

    static <T> UnaryOperator<T> identity() {
        return t -> t;
    }
}
Enter fullscreen mode Exit fullscreen mode

Input: an object of type T.
Output: another object, also of type T.
Purpose: express a transformation that never changes the type, only the value.

Why Use UnaryOperator?

You could always write this with a plain Function<T, T>:

Function<String, String> trim = String::trim;
Enter fullscreen mode Exit fullscreen mode

That works, but it doesn't say anything extra about the shape of the transformation, a reader still has to check both generic parameters to confirm they match. UnaryOperator states the contract directly:

UnaryOperator<String> trim = String::trim;
Enter fullscreen mode Exit fullscreen mode

This one-type guarantee matters most where the JDK itself expects it, like List.replaceAll.

Practical Examples

1. In-Place List Transformation

List<String> names = new ArrayList<>(List.of("filipe", "andré", "borba"));

UnaryOperator<String> capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1);

names.replaceAll(capitalize);
// [Filipe, André, Borba]
Enter fullscreen mode Exit fullscreen mode

2. Simple Same-Type Math

UnaryOperator<Integer> increment = n -> n + 1;

System.out.println(increment.apply(41)); // 42
Enter fullscreen mode Exit fullscreen mode

3. Composition (Inherited from Function)

UnaryOperator<String> trim = String::trim;
UnaryOperator<String> upper = String::toUpperCase;

Function<String, String> pipeline = trim.andThen(upper);

System.out.println(pipeline.apply("  borba  ")); // "BORBA"
Enter fullscreen mode Exit fullscreen mode

4. Iterative Generation with Stream.iterate

UnaryOperator<Integer> doubleIt = n -> n * 2;

List<Integer> powersOfTwo = Stream.iterate(1, doubleIt)
    .limit(5)
    .toList();

System.out.println(powersOfTwo); // [1, 2, 4, 8, 16]
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

  • In-Place Normalization: Trimming, upper/lower-casing, or sanitizing collections via replaceAll.
  • State Transitions: Modeling a value moving to the next state of the same type, e.g. an Order with an updated status.
  • Iterative Algorithms: Feeding Stream.iterate or recursive-style computations that keep producing the same type.
  • Configuration Overrides: Applying a same-type patch or decorator to a config or builder object.

Best Practices

  • Prefer Immutability: Return a new instance instead of mutating the input, especially for domain objects.
  • Reserve It for Same-Type Transforms: If input and output types differ, use Function<T, R> instead, don't force the fit.
  • Name for the Operation: increment, trim, capitalize communicate intent better than op.

Common Pitfalls

  • Silent Mutation: Since T equals T, it's tempting to mutate the argument directly instead of returning a new value, breaking purity.
  • Forcing the Fit: Wrapping a Function<T, R> as UnaryOperator<T> by ignoring a type mismatch defeats its purpose.
  • Overusing in Long Compositions: A long chain of andThen calls can become as unreadable as the imperative code it replaced.

Functional Analogy

Think of UnaryOperator as a polishing machine:

  • Raw material T goes in.
  • The machine reshapes it.
  • What comes out is still T, just refined.

Conclusion

UnaryOperator is Function's disciplined sibling; it exists to make same-type transformations explicit and safe. Wherever a value needs to become a better version of itself, in place, in a pipeline, or across iterations, UnaryOperator is the right tool.

What's Next

In the next episode, we'll meet BinaryOperator, a step further: instead of transforming one value, it combines two values of the same type into one. Get ready to reduce.

Top comments (0)