Lazy<T>
Runnable example:
LazySample.java
What is Lazy<T>?
Lazy<T> represents a value whose computation is deferred until first access
and then memoized — the supplier is called at most once.
Lazy is a plain final class (not a sealed interface) with a single factory method.
Use it when:
- A computation is expensive and may not always be needed.
- You need a thread-safe singleton without
staticinitializer boilerplate. - You want to break circular initialization dependencies between components.
- You need a lazy default for
getOrElseGetthat is also shared across call sites.
Thread-safety: memoization is implemented with a volatile state field and a
synchronized block (double-checked locking). The supplier is guaranteed to run
at most once even under concurrent access. Both the successful value and any
thrown exception are memoized — subsequent get() calls rethrow the same exception
without re-invoking the supplier.
Null policy: the supplier must return a non-null value (@NullMarked).
Use Lazy<Option<T>> to model a lazily evaluated result that may be absent.
Creating instances
// Standard: supply a computation to be deferredLazy<Config> config = Lazy.of(() -> Config.loadFromDisk());
// From a CompletableFuture — blocks on get(), not on constructionLazy<Report> report = Lazy.fromFuture(CompletableFuture.supplyAsync(() -> buildReport()));
// Nullable results: wrap in Option<T>, not Lazy<T> directly// (the supplier must return non-null)Lazy<Option<Config>> maybeConfig = Lazy.of(() -> Option.ofNullable(tryLoadConfig()));Accessing the value
Lazy<Config> config = Lazy.of(() -> Config.loadFromDisk());
config.isEvaluated(); // false — supplier not yet called
Config c1 = config.get(); // supplier called here; result is cachedConfig c2 = config.get(); // returns cached value — supplier NOT called again
config.isEvaluated(); // true
// If the supplier throws, the exception is memoized too.// Every subsequent get() call rethrows the same exception without// re-invoking the supplier.Lazy<String> broken = Lazy.of(() -> { throw new RuntimeException("oops"); });broken.get(); // throws RuntimeException("oops")broken.get(); // throws the same cached exception againisEvaluated() returns true after the first get() call.
You can use it to check whether the cost has already been paid without triggering
evaluation.
Transforming without forcing evaluation
map and flatMap both return a new, unevaluated Lazy. No supplier is called
until the terminal get() is invoked on the result.
Lazy<Config> config = Lazy.of(() -> Config.loadFromDisk());
// map — creates a new Lazy; the function is NOT called until the result is accessedLazy<String> appName = config.map(Config::appName);Lazy<Integer> timeout = config.map(c -> c.timeoutMs() / 1000);
// flatMap — composes two lazy computations without forcing eitherLazy<DataSource> ds = config .flatMap(c -> Lazy.of(() -> DataSource.connect(c.dbUrl())));
// Nothing has been evaluated yet — neither config nor ds have called their suppliersboolean stillLazy = !config.isEvaluated() && !ds.isEvaluated(); // true
// Only when the terminal value is requested do the suppliers run in sequenceDataSource live = ds.get(); // evaluates config, then DataSource.connect(...)Interoperability
| Method / Factory | Forces evaluation? | Returns | Notes |
|---|---|---|---|
get() | Yes | T | Rethrows supplier exceptions. |
toTry() | Yes | Try<T> | Captures exceptions as Failure; result memoized. |
toResult() | Yes | Result<T, Throwable> | Exception becomes Err. |
toResult(errorMapper) | Yes | Result<T, E> | Maps exception to a domain error. |
toOption() | Yes | Option<T> | Throws if supplier throws. |
toFuture() | If already cached | CompletableFuture<T> | Returns completed future if cached; async otherwise. |
Lazy.fromFuture(future) | No | Lazy<T> | Defers the blocking join to get() time. |
Lazy<Config> config = Lazy.of(() -> Config.loadFromDisk());
// toTry() — forces evaluation; captures any exception as Failure (result is memoized)Try<Config> t = config.toTry();
// toResult() — forces evaluation; exception becomes Err(Throwable)Result<Config, Throwable> r1 = config.toResult();
// toResult(errorMapper) — forces evaluation; maps the exception to a domain errorResult<Config, AppError> r2 = config.toResult(ex -> new AppError("Config load failed", ex));
// toOption() — forces evaluation; wraps value in Some (throws if supplier throws)Option<Config> opt = config.toOption();
// toFuture() — if already evaluated, returns a completed future immediately;// otherwise evaluates asynchronously via CompletableFuture.supplyAsyncCompletableFuture<Config> future = config.toFuture();
// fromFuture — defers the blocking join until get() is calledLazy<Config> fromAsync = Lazy.fromFuture(CompletableFuture.supplyAsync(() -> Config.loadFromDisk()));When to use Lazy
| Scenario | Recommendation |
|---|---|
| Expensive singleton computed once across the app | Lazy<T> as a static field |
| Default value that is expensive to compute | getOrElseGet(lazy::get) |
| Optional expensive computation (result may be absent) | Lazy<Option<T>> |
| Computation that should repeat on every call | Plain supplier — not Lazy |
| Async computation whose result you want to defer | Lazy.fromFuture(...) |
Common pitfalls
Using Lazy for side-effecting or repeatable actions
The supplier runs exactly once. If the expectation is that an action repeats
on every call, Lazy is the wrong tool.
// Bad: side-effecting supplier — the email is sent exactly once, not on every call.// If callers expect the action to repeat, Lazy is the wrong tool.Lazy<Void> send = Lazy.of(() -> { emailService.sendWelcome(user); return null; });send.get(); // sends emailsend.get(); // does NOT send again — memoized
// Bad: supplier returns null — Lazy is @NullMarked and will throw NullPointerExceptionLazy<String> bad = Lazy.of(() -> null); // NPE on get()
// Good: use Lazy<Option<T>> for nullable resultsLazy<Option<String>> safe = Lazy.of(() -> Option.ofNullable(findValue()));Eager evaluation via getOrElse
getOrElse(value) evaluates its argument eagerly, defeating the purpose of deferral.
Always prefer the supplier overload — or store the expensive default in its own Lazy.
// Bad: getOrElse accepts a value — the expensive default is always computed,// even when the Option is Some.Option<Config> opt = maybeConfig.getOrElse(Config.loadFromDisk()); // always loads!
// Good: use the supplier overload so the default is deferredOption<Config> opt2 = maybeConfig.getOrElseGet(() -> Config.loadFromDisk());
// Even better: store the expensive default itself in a LazyLazy<Config> fallback = Lazy.of(() -> Config.loadFromDisk());Config config = maybeConfig.getOrElseGet(fallback::get);Real-world example
Application-wide singletons declared as static final Lazy<T> fields.
Each is evaluated at most once, only when first needed, without any manual
synchronization or static initializer blocks.
// Application-wide singletons — each is computed at most once, only when first needed.// Thread-safe: double-checked locking ensures the supplier runs exactly once.
public class AppContext {
// Configuration loaded from disk — deferred until first use private static final Lazy<AppConfig> CONFIG = Lazy.of(() -> AppConfig.loadFromDisk(Paths.get("config/app.yaml")));
// DataSource depends on config — not initialized until config is ready private static final Lazy<DataSource> DATA_SOURCE = CONFIG.flatMap(cfg -> Lazy.of(() -> DataSource.connect(cfg.dbUrl())));
// FeatureFlags also depends on config; independently lazy private static final Lazy<FeatureFlags> FLAGS = CONFIG.map(cfg -> FeatureFlags.from(cfg.featureSection()));
public static AppConfig config() { return CONFIG.get(); } public static DataSource dataSource() { return DATA_SOURCE.get(); } public static FeatureFlags flags() { return FLAGS.get(); }}
// In application bootstrap — only what is actually needed is evaluated:if (AppContext.flags().isEnabled("new-ui")) { // CONFIG and FLAGS are evaluated here; DATA_SOURCE is NOT touched renderNewUi();}