The Java Realization / JBCT course / Part I — Foundations

The Four Return Types

Lesson 3 of 4 · Part I — Foundations

In this lesson

What You’ll Learn


Overview

Every function in JBCT returns one of four semantic shapes: T, Option<T>, Result<T>, or Promise<T>. Not “usually” or “preferably”—one of the four, always. Two qualifications keep the rule exact rather than merely emphatic. The shapes compose in one permitted way, Result<Option<T>> and its asynchronous form Promise<Option<T>>, where fallibility and optionality are genuinely independent concerns; every deeper nesting is a smell this chapter names later. And void remains available at the edges as a deliberate signal that failure is irrelevant to the caller, distinct from Result<Unit>, where it is not. This isn’t an arbitrary restriction; it’s intentional compression of complexity into type signatures.

Why by criteria:


T - Synchronous, Cannot Fail, Always Present

Use this when the operation is pure computation with no possibility of failure or missing data. Mathematical calculations, transformations of valid data, simple getters. If you can’t think of a way this function could fail or return nothing, it returns T.

public record FullName(String value) {
    public String initials() {  // returns String (T)
        return value.chars()
                    .filter(Character::isUpperCase)
                    .collect(StringBuilder::new,
                             StringBuilder::appendCodePoint,
                             StringBuilder::append)
                    .toString();
    }
}

The signature String initials() tells you: this always succeeds, always returns a value, completes immediately.

Return T when:


Option<T> - Synchronous, Cannot Fail, May Be Missing

Use this when absence is a valid outcome but failure isn’t possible. Lookups that might not find anything, optional configuration, nullable database columns when null is semantically meaningful. The key: missing data is normal business behavior, not an error.

public interface PreferenceRepository {
    Option<Theme> findThemePreference(UserId id);  // might not be set
}

The signature Option<Theme> tells you: this always succeeds, but the value might be absent. The caller must handle both cases.

Return Option<T> when:


Result<T> - Synchronous, Can Fail, Business/Validation Errors

Use this when an operation might fail for business or validation reasons. Parsing input, enforcing invariants, business rules that can be violated. Failures are represented as typed Cause objects, not exceptions.

Note: Cause represents domain failures or error reasons. Think of it as “FailureReason” or “DomainError”—the typed representation of why a business operation failed.

public record Email(String value) {
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
    private static final Fn1<Cause, String> INVALID_EMAIL =
        Causes.forOneValue("Invalid email format: %s");

    public static Result<Email> email(String raw) {
        return Verify.ensure(raw, Verify.Is::present)
                     .map(String::trim)
                     .filter(INVALID_EMAIL, EMAIL_PATTERN.asMatchPredicate())
                     .map(Email::new);
    }
}

The signature Result<Email> tells you: this might fail (invalid format), completes immediately, failure is typed.

Return Result<T> when:

Why Result: Error Handling Philosophy

Error handling logic belongs where business context exists to make decisions. Sometimes that’s close to where the error occurred; sometimes the error propagates unchanged because only the caller has enough context to decide.

Different mechanisms have distinct trade-offs:

Mechanism Transparency Ergonomics Reliability
Checked exceptions ✅ Explicit ❌ Verbose, coupling ✅ Compiler-enforced
Unchecked exceptions ❌ Hidden ⚠ Mental overhead ❌ Silent failures
Errors as values (Go) ✅ Visible ❌ Manual propagation ❌ Easy to ignore
Functional (Result) ✅ In type ✅ Monadic composition ✅ Compiler-enforced

Result<T> combines the best: explicit in signature, ergonomic via map/flatMap, compiler-verified handling.


Promise<T> - Asynchronous, Can Fail

Use this for any operation that leaves the process: I/O, external service calls, inter-process communication. Promise<T> is semantically equivalent to Result<T> but asynchronous - failures are carried in the Promise itself, not nested inside it.

public interface AccountRepository {
    Promise<Account> findById(AccountId id);  // async lookup, can fail
}

The signature Promise<Account> tells you: this completes later (async), might fail (network, database), failure is carried in the Promise.

Promise as Async Result

Think of Promise<T> as the asynchronous counterpart to Result<T>. Both represent operations that can succeed or fail with typed errors. The only difference is timing: Result<T> completes immediately, Promise<T> completes later. The same map/flatMap patterns work identically; converting is trivial (result.async() lifts to Promise, promise.await() blocks to Result). When you understand Result<T>, you understand Promise<T>.

Promise Resolution and Thread Safety:

Promise resolution is thread-safe and happens exactly once:

var promise = Promise.<User>promise();

// Multiple threads racing to resolve - only first wins
executor.submit(() -> promise.succeed(user1));  // First to resolve
executor.submit(() -> promise.succeed(user2));  // Ignored

// All transformations see the same result (user1)
promise.map(this::processUser)
       .flatMap(this::saveToDatabase)
       .onSuccess(this::logSuccess);

Return Promise<T> when:

A long-running computation is not on this list. CPU-bound work returns Result<T> no matter how long it takes: Promise marks crossing the process boundary, and how an operation is scheduled is the caller’s decision, made visible at the composition site (Promise.lift), never encoded in the leaf’s type.


Why Exactly Four?

These four types form a complete basis for composition. You can lift “up” when needed (Option to Result to Promise), but you never nest the same concern twice.

Each type represents one orthogonal concern:

Decision table:

Sync? Can Fail? May Be Absent? Return Type
Yes No No T
Yes No Yes Option<T>
Yes Yes No Result<T>
Async Yes No Promise<T>

Return Type Matrix

Allowed Return Types

Type Use Case
T Synchronous, cannot fail, always present
Option<T> Synchronous, cannot fail, might be absent
Result<T> Synchronous, can fail
Promise<T> Asynchronous, can fail
Result<Option<T>> Optional value that can fail validation
Promise<Option<T>> Async lookup that might not find anything

Discouraged

Type Why Discouraged
Optional<T> Use Option<T> for consistency
CompletableFuture<T> Use Promise<T> for consistent error handling
Framework-specific types (Mono<T>, ResponseEntity<T>) Keep business logic framework-agnostic

Forbidden (Double-Monad Nesting)

Type Why Forbidden
Promise<Result<T>> Promise already carries failures - double error channel
Result<Result<T>> Nested failures create unwrapping ceremony
Option<Option<T>> Nested optionality is meaningless
Promise<Option<Result<T>>> Triple nesting - architectural smell
Option<List<T>> A collection already carries emptiness as a value - a second absence channel says it twice

Rule: Each concern (optionality, failure, asynchrony) appears at most once in a return type; emptiness is the collection’s own concern, already carried as a value.


Why Not Java’s Built-in Types?

“Can’t I just use Optional, CompletableFuture, and exceptions?”

You could, but you’d hit these problems:

Java Approach Problem JBCT Solution
return null Hidden optionality → NPE at runtime Option<T> explicit in type
Optional<T> Can’t represent failures Result<T> for typed errors
try-catch Invisible control flow Result<T> - errors as values
CompletableFuture<T> Complex error handling Promise<T> consistent patterns

Key differences:


Type Conversions

Lifting: Moving Between Types

You can lift a “lower” type into a “higher” one:

// T → Option/Result/Promise
Option.option(value)
Result.success(value)
Promise.success(value)

// Option → Result/Promise
option.toResult(cause)
option.async(cause)

// Result → Promise
result.async()

Example:

public Promise<Response> execute(Request request) {
    return ValidRequest.validRequest(request)
                       .async()  // Result → Promise
                       .flatMap(step1::apply)
                       .flatMap(step2::apply);
}

Forbidden Nesting: Promise<Result<T>>

Promise<Result<T>> is forbidden. Promise<T> already carries failures - nesting Result inside creates two error channels.

Wrong:

Promise<Result<User>> loadUser(UserId id) { /* ... */ }

// Caller must unwrap twice - absurd ceremony
loadUser(id)
    .flatMap(resultUser -> resultUser.fold(
        Cause::promise,
        user -> Promise.success(user)
    ));

Right:

Promise<User> loadUser(UserId id) { /* ... */ }

// Caller just chains
return loadUser(id).flatMap(nextStep);

Allowed Nesting: Result<Option<T>>

Result<Option<T>> is permitted sparingly for “optional value that can fail validation.”

Result<Option<ReferralCode>> refCode = ReferralCode.referralCode(input);
// Success(None) = not provided, valid
// Success(Some(code)) = provided and valid
// Failure(cause) = provided but invalid

Use Verify.ensureOption() (Pragmatica Core 0.9.0+) to implement this pattern:

public static Result<Option<ReferralCode>> referralCode(String raw) {
    return Verify.ensureOption(
        Option.option(raw).map(String::trim).filter(s -> !s.isEmpty()),
        PATTERN.asMatchPredicate(),
        INVALID_FORMAT
    ).map(opt -> opt.map(ReferralCode::new));
}

API Quick Reference

Type Conversions

// Option conversions
option.toResult(cause)      // Option → Result
option.async(cause)         // Option → Promise
option.toOptional()         // Option → Java Optional

// Result conversions
result.async()              // Result → Promise
result.option()             // Result → Option (loses error)

// Promise conversions
promise.await()             // Promise → Result (blocks)
promise.await(timeout)      // Promise → Result (with timeout)

// Cause conversions (preferred)
cause.result()              // Cause → Result failure
cause.promise()             // Cause → Promise failure

Creating Instances

// Option
Option.some(value)
Option.none()
Option.option(nullable)     // null → none

// Result
Result.success(value)
cause.result()              // Preferred for failure

// Promise
Promise.success(value)
cause.promise()             // Preferred for failure
Promise.promise()           // Unresolved

Key Takeaways

  1. Four types cover all cases - T, Option, Result, Promise
  2. Signatures tell everything - failure modes, optionality, synchrony
  3. Lift up, never nest - Convert to higher types, never Promise<Result<T>>
  4. Consistent composition - Same map/flatMap across all types
  5. Use Cause methods - Prefer cause.result() over Result.failure(cause)

When void Is the Right Return Type

The four return kinds cover cases where the caller uses the result. But there’s a distinct category where no caller ever inspects the outcome: fire-and-forget side effects.

Two legitimate cases:

  1. API Conformance — An external API requires void (JDK’s Runnable, framework event handlers)
  2. Fire-and-Forget Side Effects — The caller deliberately discards the outcome (metrics, event publishing, cache warming)
// Fire-and-forget — caller doesn't care about outcome
void recordLatency(String operation, Duration elapsed);
void publishDomainEvent(OrderPlaced event);

The void return type is a semantic signal: errors are handled internally, never propagated to the caller. Compare:

Note: The Void type parameter (as in Result<Void>) remains forbidden — use Unit. The void return type is the fire-and-forget signal.


Exercises

See Appendix B for exercises on:


What’s Next

Pragmatica Core Essentials covers Pragmatica Core - the library that provides these four types. You’ll learn the API in depth and see common usage patterns.

Exercise — Return Type Selection ~10 min

For six methods in your own codebase (a pure calculation, an optional lookup, a parser, a remote call, a validator, a cache read), write down which of the four return types each should use and why. Solution discussion in the book’s Appendix B (Exercise 1.1).