Let’s consider a simple example project, appointments, where each appointment has a date, doctor, patient, and comments. These records are read from input, displayed in list or tabular form and saved. The code involves multiple layers — reading and writing I/O, formatting data, and writing formatted views.
Here’s the actual Java representation of an appointment:
public record Appointment(LocalDate date, String doctor, String patient, String comments) {}Early in the design, many of the functions that manipulate these appointments had verbose, deeply nested type signatures like BiFunction<List<String>, Supplier<Stream<List<String>>>, String>. While correct, such signatures obscure intent. That’s where type abstractions come in — small, domain-specific type aliases that make the structure of the code clearer and more readable.
The Problem: Overly Complex Function Signatures
Consider this example, which takes a list of headers and a content supplier, producing a string representation of a list view (the table view is omitted for brevity) :
public static BiFunction<List<String>, Supplier<Stream<List<String>>>, String>
listFormat =
(headers, content) ->
header(headers).append(data(content)).toString();This works, but the type signature is long and opaque. It doesn’t immediately tell you what the function is about — only that it’s a BiFunction of lists and streams that returns a string.
The Solution: Creating Semantic Type Aliases
Java doesn’t have native type aliases, but we can emulate them through interfaces that extend existing types. For instance, we can define a View type that captures the concept of a function that takes headers and content, and returns a formatted string:
public interface Types {
interface View extends BiFunction<
Collection<String>,
Supplier<Stream<Collection<String>>>, String> {}
}With this, our previous function becomes much cleaner:
public static View listFormat = (headers, content) ->
header(headers).append(data(content)).toString();The functionality hasn’t changed — but the intent is now explicit. We’ve moved from a generic, mechanical type to a domain-level concept: a View.
Extending the Abstractions
Next, the display function — responsible for rendering appointments — takes a View (the formatter) and returns a ViewWriter<IO> (the executor that writes the formatted output to an IO stream). Originally, its signature was difficult to read:
public static Function<
BiFunction<List<String>, Supplier<Stream<List<String>>>, String>,
BiFunction<List<String>, Supplier<Stream<List<String>>>, Consumer<IO>>>
display = ...Using type abstractions, this becomes far more expressive:
public static Function<View, ViewWriter<IO>> display =
view ->
(headers, content) -> io ->
io.print(content.get().count() == 0 ? "No appointments found\n"
: view.apply(headers, content));Here’s how the types break down:
- View — the formatter: takes headers and content, produces a string representation of the table.
- ViewWriter<W> — the executor: takes headers and content, and produces a side effect by writing the formatted string to a consumer of type W (like IO).
interface ViewWriter<W> extends BiFunction<
Collection<String>,
Supplier<Stream<Collection<String>>>,
Consumer<W>> {}This separation keeps formatting logic distinct from side-effect logic: View handles what the output looks like, and ViewWriter handles where it goes.
Abstracting Read and Write Operations
Finally, the function responsible for reading a new appointment from input and writing it is defined as:
public static ReadWriter<Appointment, TypedIO> addNew =
writer ->
reader ->
writer.accept(new Appointment(
reader.readDate("Enter date: ", "invalid date"),
reader.readString("Enter doctor: ", "").orElse(""),
reader.readString("Enter patient: ", "").orElse(""),
reader.readString("Enter comments (if any): ", "").orElse("")));Its type alias is equally simple and expressive:
interface ReadWriter<R, W> extends Function<Consumer<R>, Consumer<W>> {}Rather than dealing with nested Function<Consumer<X>, Consumer<Y>> constructs, we now have a ReadWriter — a function that connects two side effects: reading and writing.
The Result
These abstractions don’t change the runtime behavior of the code. They change its shape.
Now, a developer reading Function<View, ViewWriter<IO>> can immediately tell that the function takes a view and returns a view writer — no decoding of generic types required.
The result is clearer, safer, and more expressive code. Type abstractions let us represent not just data, but also intent, in the type system. They make it easier to reason about functions, compose behaviours, and test side effects in isolation — all without adding boilerplate or runtime overhead.
(Adapted from Chapter 3, “Type Abstractions,” in Dr. Software, available at bitgloss.ro/dr-software.pdf
Full example code found here)