Combining Types
Each type in dmx-fun solves a specific problem in isolation. Real applications
combine them: a Validated pipeline feeds a Result-based repository; a Try
wraps a legacy API before being converted to Option for a lookup; a
NonEmptyList carries errors collected by Validated.traverseNel.
This page covers the conversion paths between types, common composition patterns, and the anti-patterns to avoid.
Type conversion cheat sheet
| From | To | Method |
|---|---|---|
Option<T> | Result<T, E> | option.toResult(errorIfNone) |
Option<T> | Try<T> | option.toTry(exceptionSupplier) |
Try<T> | Result<T, Throwable> | tryValue.toResult() |
Try<T> | Result<T, E> | tryValue.toResult(errorMapper) |
Try<T> | Option<T> | tryValue.toOption() |
Result<T, E> | Option<T> | result.toOption() |
Result<T, E> | Try<T> | result.toTry(errorToThrowable) |
Validated<E, A> | Result<A, E> | validated.toResult() |
// Option<T> → Result<T, E>Result<String, String> r1 = option.toResult("value was absent");// or with a computed error:Result<String, AppError> r2 = option.fold(Result::ok, () -> Result.err(AppError.MISSING));
// Option<T> → Try<T>Try<String> t1 = option.toTry(() -> new NoSuchElementException("absent"));
// Try<T> → Result<T, Throwable>Result<String, Throwable> r3 = tryValue.toResult();
// Try<T> → Result<T, E> (map the exception to a domain error)Result<String, AppError> r4 = tryValue.toResult(ex -> AppError.from(ex));
// Try<T> → Option<T>Option<String> o1 = tryValue.toOption(); // Some on success, None on failure
// Result<T, E> → Option<T> (discards the error)Option<String> o2 = result.toOption(); // Some(value) or None
// Result<T, E> → Try<T>Try<String> t2 = result.toTry(AppError::toException);
// Validated<E, A> → Result<A, E>Result<UserForm, List<String>> r5 = validated.toResult();General rule: stay in one type for as long as possible, and convert only at
the boundary where the next operation demands a different type. Each conversion
may discard information (e.g. toOption() silently drops the error).
Pattern: validate then persist
Use Validated to accumulate all errors up front, then convert to Result
before calling the repository or service that operates in fail-fast mode.
// Validate with Validated — accumulate ALL errors, then hand off to the// repository which speaks Result (fail-fast, single error path).
public Result<User, List<String>> register(RegistrationRequest req) {
Validated<NonEmptyList<String>, RegistrationForm> validation = validateEmail(req.email()) .combine(validatePassword(req.password()), NonEmptyList::concat, RegistrationForm::new);
// Convert Validated → Result before hitting the database return switch (validation) { case Validated.Valid<?, RegistrationForm>(var form) -> userRepository.save(form); // returns Result<User, List<String>> case Validated.Invalid<NonEmptyList<String>, ?>(var errors) -> Result.err(errors.toList()); // surface all validation errors };}Validated is the right tool for the validation layer because it accumulates
every error. Result is right for the persistence layer because there is only
one error path (database failure). Converting at the boundary keeps each layer
in its natural type.
Pattern: chain across types
Convert at the exact point where the next operation’s type demands it — not earlier.
// Chain Option → Try → Result in a single pipeline.// Convert at the boundary where the next operation's type demands it.
Option<Config> config = configStore.load(); // absent = not configured
// Option → Try: treat absence as an exceptional state for this pathTry<Connection> conn = config .toTry(() -> new MissingConfigException("database config not found")) .flatMap(cfg -> Try.of(() -> DataSource.connect(cfg.dbUrl())));
// Try → Result: surface a domain error to the callerResult<Data, String> data = conn .toResult(ex -> "Connection failed: " + ex.getMessage()) .flatMap(c -> fetchData(c)); // fetchData returns Result<Data, String>Pattern: fan-out with Validated and Tuple
Run two or more independent validations in parallel and combine their results. If either is invalid, all errors are accumulated.
// Run two independent validated computations and combine their results// into a Tuple2 — errors from both are accumulated via NonEmptyList::concat.
Validated<NonEmptyList<String>, Email> emailV = validateEmail(req.email());Validated<NonEmptyList<String>, Username> usernameV = validateUsername(req.username());
Validated<NonEmptyList<String>, Tuple2<Email, Username>> combined = emailV.combine(usernameV, NonEmptyList::concat, Tuple2::new);
// Collapse the tuple into a domain object once both values are validValidated<NonEmptyList<String>, UserDraft> draft = emailV.combine(usernameV, NonEmptyList::concat, (email, username) -> new UserDraft(email, username));Pattern: traverse across a collection
Option.traverse, Result.traverse, and Try.traverse apply a function to
every element of a collection and collect the results into a single wrapper.
All three short-circuit on the first failure.
// Option.traverse — apply a function that may return None; short-circuits on first NoneList<Long> userIds = List.of(1L, 2L, 3L);
Option<List<User>> users = Option.traverse(userIds, id -> userRepo.findById(id));// Some([user1, user2, user3]) if all are found; None if any id is missing
// Result.traverse — apply a function that may return Err; short-circuits on first ErrList<String> orderRefs = List.of("ORD-1", "ORD-2", "ORD-3");
Result<List<Order>, String> orders = Result.traverse(orderRefs, ref -> orderService.load(ref));// Ok([order1, order2, order3]) if all succeed; Err("...") on first failure
// Try.traverse — apply a checked operation to each element; stops on first exceptionList<Path> paths = List.of(Paths.get("a.txt"), Paths.get("b.txt"));
Try<List<String>> contents = Try.traverse(paths, path -> Try.of(() -> Files.readString(path)));// Success([...]) if all reads succeed; Failure(IOException) on first errorFor Validated (which does not short-circuit), use Validated.traverseNel
to accumulate errors from every element — see the
NonEmptyList guide for details.
Pattern: wrapping legacy APIs with Try
Wrap any checked or null-returning legacy API with Try, then convert to your
domain’s error type at the service boundary.
// Wrap a legacy checked API with Try, then convert to Result at the service boundary.// The caller receives a typed domain error — not a raw exception.
Result<Report, AppError> report = Try.of(() -> legacyReportService.generate(params)) // CheckedSupplier .toResult(ex -> AppError.from("report-generation-failed", ex));
// The same pattern for Optional-returning legacy APIs:Result<Config, AppError> config = Try.of(() -> legacyConfigLoader.load()) // may throw or return null .flatMap(cfg -> cfg != null ? Try.success(cfg) : Try.failure(new MissingConfigException())) .toResult(AppError::from);Anti-pattern: unnecessary conversions
Convert only when the next operation requires a different type. Converting through multiple types in a single pipeline usually means the wrong type was chosen at the start, or that an exception is being silently discarded.
// Bad: unnecessary chain of conversions — each conversion loses information// and makes the pipeline harder to follow.Result<String, AppError> result = Try.of(() -> fetchValue()) .toOption() // discards the exception — why? .toTry(() -> new RuntimeException()) // re-wraps into a less informative exception .toResult(AppError::from); // finally converts to Result
// Good: stay in Try and convert directly to Result at the endResult<String, AppError> result2 = Try.of(() -> fetchValue()) .toResult(AppError::from);
// Bad: converting to Option mid-pipeline just to call getOrElseGetString value = Try.of(() -> fetchValue()) .toOption() // exception silently discarded .getOrElseGet(() -> "default");
// Good: use Try's own recovery methods — the exception is still accessibleString value2 = Try.of(() -> fetchValue()) .getOrElse("default");// or, if you need the exception:String value3 = Try.of(() -> fetchValue()) .recover(ex -> "default (error: " + ex.getMessage() + ")");Rule of thumb: if you are converting Try → Option just to call
getOrElseGet, use Try.getOrElse or Try.recover directly. If you are
converting Result → Try → Result, there is likely a missing method on Result
— check the API first.