Most conversations about API design focus on naming: consistent verbs, noun-first method names, sensible parameter ordering. These things matter. But the most powerful form of documentation available to you never ends up in a Javadoc comment — it lives in the return type.

A method signature is a contract between the method and every caller it will ever have. Most Java APIs break that contract silently. They promise one thing in the type and deliver something else at runtime: a null where a value was expected, an exception where none was declared, a boolean that hides which of three things went wrong.

Functional types change this. When you use Option<T>, Result<V, E>, Validated<E, A>, Try<V>, or Either<L, R> in your API surface, the signature stops hiding and starts telling the truth. The compiler enforces what the method name only implies.

This post is about what that looks like in practice — across a range of real API design situations.


The Problem: Signatures That Lie

Start with three methods that any Java developer would write without hesitation:

// Returns the product — or null if not found
Product findBySku(String sku);
// Returns true if the order was accepted; false if rejected
boolean submitOrder(Order order);
// Validates the form; throws if invalid
void validate(RegistrationForm form) throws ValidationException;

Each of these has a lie embedded in its contract.

findBySku promises to return a Product. It does not. It returns a Product or null, and the caller learns this only by reading the implementation or the Javadoc — if the Javadoc exists.

submitOrder tells the caller two outcomes are possible: true and false. It does not say what happens when the payment gateway is unreachable, or when the inventory lock times out. Those failures are hidden somewhere — an unchecked exception, a logged error, a thread-local flag.

validate throws when invalid. But it also throws when the database is down (checking uniqueness), when the format check library has a bug, or when anything else goes wrong. The caller cannot distinguish validation failure from infrastructure failure from a programming error. All three are ValidationException.

None of this is negligent. It is normal Java. The problem is structural: the type system is not being used to tell the truth.


Option<T>: Making Absence Explicit

The simplest lie to fix is the nullable return.

// Before: caller has no idea null is possible
Product findBySku(String sku);
// After: absence is part of the contract
Option<Product> findBySku(String sku);

Option<T> is a sealed type with exactly two variants: Option.Some<T> (a present value) and Option.None (an absent value). The caller cannot access the value without first branching on which it is. The compiler enforces it.

Compare what calling code looks like:

// Before — null is possible, but the compiler won't tell you
Product product = catalog.findBySku(sku);
if (product != null) {
return product.price();
}
return Money.ZERO;
// After — the absence is explicit; pattern match or use a combinator
Option<Product> result = catalog.findBySku(sku);
// Option 1: combinator
Money price = result.map(Product::price).getOrElse(Money.ZERO);
// Option 2: exhaustive switch
Money price = switch (result) {
case Option.Some<Product> some -> some.value().price();
case Option.None<Product> __ -> Money.ZERO;
};

The combinator form is one line. The switch form is exhaustive — the compiler rejects it if you miss a case. Neither form can crash with a NullPointerException.

Option<T> vs Optional<T>

The JDK’s Optional<T> addresses the same problem and is a valid choice for basic absent-value contracts. Option<T> from dmx-fun adds two things that matter for API design:

First, it is annotated @NullMarked and its variants are sealed records — pattern matching is idiomatic, not bolted on. Second, it converts directly to Result, Try, and Either, which matters when your API composes multiple layers.

If you are not using other functional types and only need absent-value signaling, Optional<T> from the JDK is fine. The moment you start chaining across layers that use different types, a unified type hierarchy pays for itself.


Result<V, E>: Expressing Both Outcomes

The harder lie is the one hidden behind exceptions. When a method throws, it has two return types: the declared one and the exception hierarchy. Only one appears in the signature. The caller has to guess or read the source.

Result<V, E> makes both outcomes explicit:

// Before: two possible outcomes, one is hidden
Order processOrder(String orderId) throws PaymentDeclinedException, InsufficientStockException;
// After: both outcomes are part of the type
Result<Order, OrderError> processOrder(String orderId);

The sealed error type does the same work as the exception hierarchy — it lets callers distinguish cases:

public sealed interface OrderError
permits OrderError.PaymentDeclined,
OrderError.InsufficientStock,
OrderError.OrderNotFound {
record PaymentDeclined(String reason) implements OrderError {}
record InsufficientStock(String productId) implements OrderError {}
record OrderNotFound(String orderId) implements OrderError {}
}

Now the caller’s code is complete at compile time:

Result<Order, OrderError> result = service.processOrder(id);
ResponseEntity<OrderResponse> response = switch (result) {
case Result.Ok<Order, OrderError> ok -> ResponseEntity.ok(toResponse(ok.value()));
case Result.Err<Order, OrderError> err -> switch (err.error()) {
case OrderError.PaymentDeclined e -> ResponseEntity.status(402).body(errorBody(e.reason()));
case OrderError.InsufficientStock e -> ResponseEntity.status(409).body(outOfStockBody(e.productId()));
case OrderError.OrderNotFound e -> ResponseEntity.notFound().build();
};
};

Every case is handled. No exception leaks. No “what happens if this returns null”. The compiler verifies the exhaustion.

What belongs in Result.Err

A Result<V, E> represents an expected outcome — something a correct, running program might produce. It is not meant for:

  • Infrastructure failures (database unreachable, network timeout) — these are exceptional; log and propagate them as unchecked exceptions.
  • Programming errors (NullPointerException, ClassCastException) — these should crash loudly.
  • Anything the caller cannot meaningfully react to differently from any other failure.

The test: if the caller would write a switch on the error variants and do something different for each one, it belongs in Result.Err. If every branch would do the same thing (“log it and return a 500”), it belongs in an unchecked exception.


Try<V>: Wrapping APIs That Throw

Some APIs you cannot redesign — the standard library, legacy code, third-party SDKs. They throw checked exceptions. Using them inside a functional pipeline requires explicit wrapping.

Try<V> is a Result-like type whose error channel is always Throwable. It is the right tool at the boundary where an exception-throwing API meets your functional pipeline.

// Reading a config file: throws IOException
Config loadConfig(Path path) throws IOException;
// Wrapped at the boundary
Try<Config> config = Try.of(() -> loadConfig(configPath));
// Now composable
Try<ServerSettings> settings = config.map(Config::serverSettings);

The value of making this explicit in your own APIs: if you are wrapping a method that genuinely can throw for multiple unrelated reasons (I/O failure, parse failure, validation failure), returning Try<V> tells callers that something checked might have gone wrong — they get to decide what to do with the failure.

// Before: caller must wrap every call with try/catch
byte[] loadTemplate(String name) throws IOException;
// After: failure is part of the type; caller composes cleanly
Try<byte[]> loadTemplate(String name);

Callers of the Try-returning version can chain map, flatMap, and recover without an inner try/catch at every step.


Validated<E, A>: APIs That Accumulate

Result is fail-fast: it stops at the first error and short-circuits the pipeline. That is the right behavior for a multi-step business operation — if parsing fails, there is no point validating.

For validation of independent fields, it is the wrong behavior. If a registration form has three invalid fields, the user should see all three errors in one response, not one error per submit.

Validated<E, A> accumulates errors. The type parameter E is typically a collection type (a NonEmptyList<ValidationError>, a List<String>), and flatMap is replaced by zip — parallel composition that collects errors from both branches.

// Before: stops at the first failed field; caller gets one error per submit
void validate(RegistrationForm form) throws ValidationException;
// After: returns all errors at once
Validated<NonEmptyList<FieldError>, ValidatedForm> validate(RegistrationForm form);

The implementation becomes a composition of independent checks, each returning Validated:

public Validated<NonEmptyList<FieldError>, ValidatedForm> validate(RegistrationForm form) {
Validated<NonEmptyList<FieldError>, String> email = validateEmail(form.email());
Validated<NonEmptyList<FieldError>, String> name = validateName(form.name());
Validated<NonEmptyList<FieldError>, String> password = validatePassword(form.password());
return email.zip(name, password, ValidatedForm::new);
}

All three fields are checked independently. If two fail, both errors are in the result. The caller gets a Validated.Invalid carrying every FieldError, or a Validated.Valid carrying the validated form.

Choosing between Result and Validated

Result<V, E>Validated<E, A>
Error accumulationNo — stops at first errorYes — collects all errors
CompositionflatMap (sequential)zip (parallel)
Use caseMulti-step pipelinesIndependent field validation
Short-circuitYesNo

The two types complement each other. Use Validated to collect all errors at the input boundary, then convert to Result to proceed through a sequential pipeline once the input is known-good.


Either<L, R>: Neutral Branching

Result encodes an asymmetry: Ok is success, Err is failure. That asymmetry is the right model for most outcomes — but not all of them.

Some methods return one of two equally valid things, where neither side is a “failure”:

// An invoice that is either a draft (editable) or a finalized copy (immutable)
Either<DraftInvoice, FinalizedInvoice> getInvoice(String invoiceId);
// A routing decision: internal team or external contractor
Either<InternalTask, ContractorTask> routeTask(TaskRequest request);

Using Result here would be misleading — the Err side implies failure, but DraftInvoice is not a failure, it is a valid state. Either<L, R> models the neutral disjoint union without the semantic loading.

Callers pattern-match on which it is:

Either<DraftInvoice, FinalizedInvoice> invoice = repository.getInvoice(id);
String statusMessage = switch (invoice) {
case Either.Left<DraftInvoice, FinalizedInvoice> left -> "Draft — " + left.value().editUrl();
case Either.Right<DraftInvoice, FinalizedInvoice> right -> "Finalized on " + right.value().finalizedAt();
};

The naming convention (Left, Right) is deliberately neutral. It carries no implication about which side is preferred.


Lazy<T>: Signaling Deferred Computation

Lazy<T> is less common in API signatures, but it has a specific use: passing a value whose computation should be deferred until (and unless) the caller actually needs it.

// Before: the description is always computed, even if the item is filtered out
void log(String eventCode, String description);
// After: the description is only computed if logging is enabled
void log(String eventCode, Lazy<String> description);

This is the same idea as a supplier argument — Supplier<String> in the JDK — but Lazy<T> adds memoization: if the value is requested more than once, it is computed only on the first call.

In API design, Lazy<T> as a parameter type sends a clear signal to callers: wrap the expensive computation; I will call it only if needed. It is also honest about the method’s behavior — the method is declaring that it may not always use the argument.


A Before/After: Service Layer API

Putting these patterns together, here is a realistic service interface redesigned with functional types.

Before

public interface OrderService {
// Returns null if not found
Order findById(String id);
// Throws OrderValidationException or PaymentException or StockException
Order submit(SubmitOrderRequest request) throws OrderValidationException;
// Returns true if cancelled, false otherwise — but false could mean "not found" or "already shipped"
boolean cancel(String id);
// Throws ValidationException; stops at first error
void validateRequest(SubmitOrderRequest request) throws ValidationException;
}

Four methods. Four different conventions. Callers of each must read the implementation, the Javadoc, and probably a few stack traces before they understand the real contract.

After

public interface OrderService {
Option<Order> findById(String id);
Result<Order, OrderError> submit(SubmitOrderRequest request);
Result<Cancelled, CancelError> cancel(String id);
Validated<NonEmptyList<FieldError>, ValidatedRequest> validateRequest(SubmitOrderRequest request);
}

Four methods. Four self-documenting signatures. No Javadoc needed to understand the possible outcomes — they are encoded in the types. The compiler enforces that callers handle every case.

The contract for submit:

  • Success: an Order
  • Failure: one of the OrderError variants (each a distinct reaction point for the caller)

The contract for cancel:

  • Success: a Cancelled record (might carry the cancellation timestamp, refund amount, etc.)
  • Failure: one of the CancelError variants (not found, already shipped, policy violation)

The contract for validateRequest:

  • Valid: a ValidatedRequest — a type that carries the guarantee that validation has passed
  • Invalid: a non-empty list of FieldErrorall errors, not just the first

The Compile-Time Safety Payoff

The biggest shift functional types make to API design is not ergonomics — it is safety.

When a method returns Result<Order, OrderError>, callers cannot ignore the error case. They can map and stay in the pipeline, but at some point they must fold or switch to extract the value. At that point the compiler verifies that every OrderError variant is handled.

Add a new error variant — OrderError.FraudSuspected — and every caller with an exhaustive switch fails to compile. That is a feature, not a bug. The compiler finds every call site that needs to be updated. No grepping, no hoping someone reads the changelog.

Compare this to adding a new OrderException subclass to a throws-based API: it compiles cleanly, the new exception propagates silently to a catch (Exception e) somewhere up the stack, and production logs a stack trace in six weeks.


What Not to Over-Engineer

Functional types improve API clarity when the caller has meaningful things to do with the different outcomes. When that is not the case, they add noise without adding value.

Do not return Result<Unit, ErrorCode> from a method where the only realistic caller behavior is “log the error and move on”. A simple unchecked exception or a logged boolean is cleaner.

Do not return Option<T> from a method that only returns absent when invariants are violated — that is a NullPointerException waiting to be wrapped. If the code is calling the method with bad input, the error should be loud, not wrapped in Option.None.

Do not use Validated for sequential steps where stopping at the first error is exactly right. Validated’s parallel accumulation only helps when the errors are independent.

The question for each method: does the caller need to distinguish the outcomes? If yes, encode them in the type. If every outcome looks the same from the caller’s perspective, the extra type is overhead.


Conclusion

A method signature is the first documentation a caller reads. When it promises a Product and sometimes returns null, it is lying. When it declares a single throws ValidationException and actually throws four different things, it is incomplete. When it returns boolean and the real information is which of five things went wrong, it is hiding.

Functional types fix the lie at the source:

  • Option<T> — the value may or may not be present; absence is handled at compile time
  • Result<V, E> — the operation can succeed or fail for a structured, typed reason
  • Try<V> — the operation might throw a checked exception; the failure is a first-class value
  • Validated<E, A> — the input may have multiple independent errors; all of them are collected
  • Either<L, R> — two equally valid outcomes, neither of which is a failure
  • Lazy<T> — the value is deferred; compute it only if the method actually needs it

None of these types are a magic fix. A codebase that uses Result<V, E> consistently while still littering internal utilities with thrown exceptions and null returns has only added vocabulary without changing the underlying habits. The types pay off when the team decides that every public API surface should tell the truth — and uses the compiler to enforce it.


Further reading