better-py

Task Monad

Lazy computations with automatic memoization

The Task monad represents lazy computations that can be executed later. Once executed, the result is automatically memoized (cached) for subsequent accesses, making it ideal for expensive operations.

Task[T] wraps a computation that produces a value of type T. The computation is deferred until run() is called, and the result is cached after the first execution.

Creating Tasks

Wrapping Computations

The Task constructor wraps a function that will execute lazily when run.

from better_py import Task

task = Task(lambda: expensive_computation())
result = task.run()  # Executes and caches result

Use the constructor when you need to defer expensive operations.

Lifting Pure Values

The Task.pure method lifts a pure value into a Task context.

from better_py import Task

Task.pure(42).run()  # 42

Use this when you need to create a Task from an already-computed value.

Creating Delayed Computations

The Task.delay method creates a delayed computation that executes when run.

from better_py import Task

# Delay a value
Task.delay(42).run()  # 42

# Delay a function
Task.delay(lambda: expensive_computation()).run()

Use delay to defer expensive operations until they're actually needed.

Running Tasks

Executing and Caching

The run method executes the Task and caches the result for subsequent calls.

from better_py import Task

task = Task(lambda: 42)
result = task.run()  # 42

# Subsequent calls return cached result
result2 = task.run()  # 42 (from cache)

This is the primary way to execute Task computations.

Checking Cache Status

The is_cached method checks whether the Task has been executed.

from better_py import Task

task = Task(lambda: 42)
task.is_cached()  # False

task.run()
task.is_cached()  # True

Use this to check if a Task has already been computed.

Peeking at Cached Values

The peek method returns the cached value without executing if not yet run.

from better_py import Task

task = Task(lambda: 42)
task.peek()  # None (not yet executed)

task.run()  # 42
task.peek()  # 42 (cached)

Use peek to access cached values when you're not sure if the Task has been run.

Transforming Tasks

Mapping Results

The map method transforms the result of a Task computation.

from better_py import Task

Task(lambda: 5).map(lambda x: x * 2).run()  # 10

The transformation is only applied when the Task is run, not when it's created.

Chaining Operations

The flat_map method chains Task operations sequentially.

from better_py import Task

def fetch_data():
    return Task(lambda: [1, 2, 3])

def process_data(data):
    return Task(lambda: sum(data))

result = fetch_data().flat_map(process_data)
result.run()  # 6

Use flat_map when the second computation depends on the result of the first.

Combining Tasks

The zip method combines two Tasks into a tuple of their results.

from better_py import Task

result = Task(lambda: 5).zip(Task(lambda: "hello"))
result.run()  # (5, "hello")

Use zip when you need to run multiple independent Tasks together.

Filtering Results

The filter method conditionally returns the result or None based on a predicate.

from better_py import Task

Task(lambda: 5).filter(lambda x: x > 3).run()   # 5
Task(lambda: 2).filter(lambda x: x > 3).run()   # None

Use filter to conditionally keep or discard results.

Sequencing Operations

The and_then method runs two Tasks in order, discarding the first result.

from better_py import Task

(Task(lambda: print("First"))
    .and_then(Task(lambda: print("Second")))
    .run())
# Prints:
# First
# Second

Use and_then when you need to run side effects in sequence but don't need the first result.

Real-World Pattern: Lazy Data Fetching

from better_py import Task

def fetch_user(user_id: int) -> Task[dict]:
    return Task(lambda: database.query(f"SELECT * FROM users WHERE id = {user_id}"))

def fetch_orders(user: dict) -> Task[list]:
    return Task(lambda: api.get(f"/users/{user['id']}/orders"))

# Chain tasks
user_task = fetch_user(1)
# Task not executed yet

# Use the task later
orders = (user_task
    .flat_map(lambda user: fetch_orders(user))
    .run())
# Executes both queries, results are cached

# Use the same user_task again
user2 = user_task.run()  # Returns cached result

This pattern shows Task's power: expensive operations are deferred until needed, and results are automatically cached for subsequent accesses.

When to Use

Use Task when:

  • You want lazy evaluation
  • You need memoization/caching
  • You want to defer expensive computations
  • You're building pipelines
  • You need automatic caching
  • You want to avoid redundant computations

Don't use Task when:

  • You need immediate execution (use plain functions)
  • You don't need caching (use plain functions)
  • Side effects should happen immediately (use IO)
  • The computation is trivial

Comparison with IO

FeatureTaskIO
ExecutionLazyDeferred but explicit
CachingAutomatic memoizationNo caching
Use caseExpensive computationsSide effects
Multiple runsReturns cached valueRe-executes

Rule of thumb: Use Task for expensive pure computations that benefit from caching. Use IO for side effects that should execute each time.

See Also

  • IO - For side effect management
  • Try - For exception handling
  • Reader - For dependency injection

On this page