Lzy
Lzy is somewhere between lazy Futures, and a tiny Effect System. It provides a way to describe computations that can be composed and executed later, with built-in support for error handling, resource management, and logging.
Futures vs Lazy: A Comparison
A (Scala) Future
is an eager calculation - once referenced, it's going to run! This also means once it's done, we
can't re-run it - only evaluate the finished state. Sometimes it's beneficial to describe some logic in a lazy fashion,
where it's more of a blueprint of what to do. Calling the blueprint runs the code, whose output can be assigned to a
value, but then the blueprint can be run again!
Let's see a simple comparison between Future and Lazy:
import scala.concurrent.{Future, ExecutionContext}
import scala.util.Random
// Future Example
given ExecutionContext = LazyRuntime.executionContext
val f1: Future[Int] = Future(Random.nextInt(10))
// f1 is already running, kicked off by the ExecutionContext
val f2: Future[Int] = Future(Random.nextInt(10))
// f2 is also already running...
def fRandomSum: Future[Int] = for {
a <- f1 // Will always get the same value
b <- f2 // Will always get the same value
} yield (a + b)
// The sum will be the same every time
println(Await.result(fRandomSum, Duration.Inf))
println(Await.result(fRandomSum, Duration.Inf))
// Lazy Example
val l1: Lazy[Int] = Lazy.fn(Random.nextInt(10))
// l1 is just a description, nothing runs yet
val l2: Lazy[Int] = Lazy.fn(Random.nextInt(10))
// l2 is also just a description
def lzyRandomSum: Lazy[Int] = for {
a <- l1 // New random number each time
b <- l2 // New random number each time
} yield (a + b)
// The sum will be different each time
println(lzyRandomSum.runSync)
println(lzyRandomSum.runSync)
This lazy evaluation approach lets you:
- Describe complex operations without executing them immediately
- Re-run the same computation multiple times
- Add error handling and other effects where needed
- Control exactly when and how the computation runs
For example, we can add error handling without modifying the original computation:
def myLazyOp(arg: Int): Lazy[Int] =
Lazy.fn(42 / arg)
// Without error handling
myLazyOp(0).runSync()
// -> Failure(java.lang.ArithmeticException: / by zero)
// With error handling added at the call site
myLazyOp(0)
.recover(_ => Lazy.fn(0))
.runSync()
// => Success(0)
// Or with different error handling elsewhere
myLazyOp(0)
.recoverSome { case _: ArithmeticException => Lazy.fn(-1) }
.runSync()
// => Success(-1)
Core Features
Lzy provides a rich set of features to help you build and compose lazy computations. At its core, Lzy is built around the concept of describing computations rather than executing them immediately. This allows you to:
- Build complex workflows by composing smaller operations
- Handle errors gracefully with various recovery strategies
- Control execution timing and flow
- Work with resources safely
- Integrate logging seamlessly
- Process collections efficiently
All operations in Lzy are lazy by default - they're just descriptions until you explicitly run them using runSync
or runAsync
.
Creating Lazy Values
There are several ways to create Lazy values:
// From a direct value
val simple: Lazy[Int] = Lazy.fn(42)
// From a computation
val computed: Lazy[Int] = Lazy.fn {
println("Computing...")
42
}
// From a Try
val fromTry: Lazy[Int] = Lazy.fromTry(Try(42 / 0))
// From an Option
val fromOption: Lazy[Int] = Lazy.fromOption(Some(42))
// Create a failed computation
val failed: Lazy[Int] = Lazy.fail(new Exception("Boom!"))
// Get current time
val now: Lazy[Instant] = Lazy.now()
Error Handling
Lzy provides several ways to handle errors:
def myLazyOp(arg: Int): Lazy[Int] =
Lazy.fn(42 / arg)
// Basic recovery
myLazyOp(0).recover(_ => Lazy.fn(0))
// Recover with pattern matching
myLazyOp(0).recoverSome {
case _: ArithmeticException => Lazy.fn(0)
}
// Provide a default value
myLazyOp(0).orElseDefault(0)
// Map/tap errors
myLazyOp(0)
.mapError(e => new Exception(s"Failed with: ${e.getMessage}"))
.tapError(e => println(s"Error occurred: ${e.getMessage}"))
// Retry on failure
myLazyOp(0).retryN(3) // Retry up to 3 times
Timing Control
You can add delays and pauses to your Lazy computations:
import scala.concurrent.duration._
Lazy.fn("hello")
.delay(1.second) // Delay before execution
.pause(500.millis) // Pause after execution
Optional Values
Work with optional values elegantly:
// Convert success to Some, failure to None
val optional: Lazy[Option[Int]] = myLazyOp(0).optional
// Run computation only if condition is true
val conditional: Lazy[Option[Int]] = Lazy.when(someCondition)(myLazyOp(42))
// Pattern match with whenCase
val matched: Lazy[Option[Int]] = Lazy.whenCase(value) {
case n if n > 0 => Lazy.fn(n * 2)
}
// Handle Options
Lazy.fromOption(Some(42)) // Convert Option to Lazy
.getOrElse(0) // Default if None
Resource Management
Safely work with resources that need to be released:
import scala.util.Using
// Resource is automatically closed after use
Lazy.using(scala.io.Source.fromFile("data.txt")) { source =>
source.getLines().toList
}
Logging Integration
Built-in logging support with different log levels:
import java.util.logging.Logger
given logger: Logger = Logger.getLogger("MyApp")
for {
_ <- Lazy.logInfo("Starting operation")
result <- myLazyOp(42)
_ <- Lazy.logFine(s"Operation completed with: $result")
_ <- Lazy.logConfig("Configuration updated")
} yield result
Collection Operations
Work with collections efficiently:
// Iterate over collections
Lazy.iterate(Iterator(1,2,3))(List.newBuilder[Int])(_ => Lazy.fn(1))
// Process each element
Lazy.forEach(List(1,2,3))(n => Lazy.fn(n * 2))
Composing Operations
Lazy values can be composed using for-comprehensions or combinators:
// Using for-comprehension
for {
a <- Lazy.fn(40)
b <- Lazy.fn(2)
_ <- Lazy.logInfo(s"Adding $a and $b")
sum <- Lazy.fn(a + b)
} yield sum
// Using combinators
Lazy.fn(40)
.flatMap(a => Lazy.fn(2).map(b => a + b))
.tap(sum => Lazy.logInfo(s"Sum is $sum"))
Running Lazy Values
There are several ways to execute a Lazy value:
val myLazy: Lazy[Int] = Lazy.fn(42)
// Synchronous execution
val result: Try[Int] = myLazy.runSync
val resultWithTimeout: Try[Int] = myLazy.runSync(5.seconds)
// Asynchronous execution
val future: Future[Int] = myLazy.runAsync
Creating Lazy Applications
You can easily create full applications using the LazyApp
trait:
object MyApp extends LazyApp {
// Customize the execution context if needed
override val executionContext: ExecutionContext =
BranchExecutors.executionContext
// Define your main application logic
override def run: Lazy[Any] = for {
_ <- Lazy.logInfo("Starting application")
result <- myLazyOp(42)
_ <- Lazy.logInfo(s"Got result: $result")
} yield result
}
The LazyApp
trait provides:
- A default virtual-thread-per-task execution context
- Automatic handling of the main method
- Built-in logging support
Other Libraries
If you like Lzy, you should check out ZIO for a more comprehensive effect system.