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 static initializer boilerplate.
  • You want to break circular initialization dependencies between components.
  • You need a lazy default for getOrElseGet that 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 deferred
Lazy<Config> config = Lazy.of(() -> Config.loadFromDisk());
// From a CompletableFuture — blocks on get(), not on construction
Lazy<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 cached
Config 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 again

isEvaluated() 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 accessed
Lazy<String> appName = config.map(Config::appName);
Lazy<Integer> timeout = config.map(c -> c.timeoutMs() / 1000);
// flatMap — composes two lazy computations without forcing either
Lazy<DataSource> ds = config
.flatMap(c -> Lazy.of(() -> DataSource.connect(c.dbUrl())));
// Nothing has been evaluated yet — neither config nor ds have called their suppliers
boolean stillLazy = !config.isEvaluated() && !ds.isEvaluated(); // true
// Only when the terminal value is requested do the suppliers run in sequence
DataSource live = ds.get(); // evaluates config, then DataSource.connect(...)

Interoperability

Method / FactoryForces evaluation?ReturnsNotes
get()YesTRethrows supplier exceptions.
toTry()YesTry<T>Captures exceptions as Failure; result memoized.
toResult()YesResult<T, Throwable>Exception becomes Err.
toResult(errorMapper)YesResult<T, E>Maps exception to a domain error.
toOption()YesOption<T>Throws if supplier throws.
toFuture()If already cachedCompletableFuture<T>Returns completed future if cached; async otherwise.
Lazy.fromFuture(future)NoLazy<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 error
Result<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.supplyAsync
CompletableFuture<Config> future = config.toFuture();
// fromFuture — defers the blocking join until get() is called
Lazy<Config> fromAsync = Lazy.fromFuture(CompletableFuture.supplyAsync(() -> Config.loadFromDisk()));

When to use Lazy

ScenarioRecommendation
Expensive singleton computed once across the appLazy<T> as a static field
Default value that is expensive to computegetOrElseGet(lazy::get)
Optional expensive computation (result may be absent)Lazy<Option<T>>
Computation that should repeat on every callPlain supplier — not Lazy
Async computation whose result you want to deferLazy.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 email
send.get(); // does NOT send again — memoized
// Bad: supplier returns null — Lazy is @NullMarked and will throw NullPointerException
Lazy<String> bad = Lazy.of(() -> null); // NPE on get()
// Good: use Lazy<Option<T>> for nullable results
Lazy<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 deferred
Option<Config> opt2 = maybeConfig.getOrElseGet(() -> Config.loadFromDisk());
// Even better: store the expensive default itself in a Lazy
Lazy<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();
}