Tutorial
A comprehensive guide to using the talvez
library for safe, composable
handling of optional values and fallible computations in Python.
1. Introduction
talvez
provides small functional primitives centered around a Maybe
type,
inspired by Haskell, other functional programming ecosystems, and the R {maybe}
package. It includes decorators to safely wrap existing functions and compose
computations that might fail.
The core problem talvez
addresses is the inconsistent and often implicit
handling of failures in Python. Functions can fail by raising exceptions,
returning None
, or returning special values like -1
or False
. This forces
developers to write defensive code with scattered try/except
blocks and
if value is not None:
checks, making the main logic harder to follow.
Core goals:
- Eliminate scattered
try/except
blocks: By wrapping fallible operations,talvez
contains failures within a predictable structure, allowing you to handle them explicitly when you choose. - Make failure explicit: Instead of relying on exceptions or ambiguous
None
returns, failure is represented by a single, explicit type:Nothing
. This makes your function signatures more honest about what they can return. - Provide ergonomic wrappers (
@maybe
,@perhaps
): These decorators allow you to easily retrofit existing code to use this safer pattern without major refactoring. - Offer simple combinators for building pipelines: Easily chain together multiple fallible operations in a readable, robust way.
2. Installation
Just use whatever package manager is the current popular one.
3. The Maybe Type
The Maybe
type is the heart of the library. It's a generic container that
represents a value that may or may not be present. A Maybe
is one of two
things:
Just(value)
: A container holding a successful result.Nothing
: An empty container representing any kind of failure (an exception occurred, a validation check failed, etc.).
This explicitness is its power. A function that returns Maybe[int]
tells you
it will either give you an int
(wrapped in Just
) or it will give you
Nothing
.
from talvez import just, nothing, Maybe
# Create a Maybe holding a value
x = just(42)
print(x) # Output: Just(42)
# Create a Maybe representing failure
y = nothing()
print(y) # Output: Nothing
# You can check the state of a Maybe
assert x.is_just is True
assert x.is_nothing is False
assert y.is_nothing is True
Key Methods
A Maybe
's value is wrapped and cannot be accessed directly. You interact with it through safe methods.
-
fmap(fn)
: (Functor map) If theMaybe
is aJust
, it applies a regular Python function to the inner value and wraps the result in a newJust
. If it'sNothing
, it does nothing and returnsNothing
.python just(10).fmap(lambda v: v + 5) # Returns Just(15) nothing().fmap(lambda v: v + 5) # Returns Nothing just("hello").fmap(str.upper) # Returns Just('HELLO')
-
bind(fn)
: (Monadic bind) This is for chaining functions that already return aMaybe
. If theMaybe
is aJust
,bind
applies the function to the inner value. If it'sNothing
, it returnsNothing
. This is the primary tool for building pipelines.```python
A function that can fail and returns a Maybe
def safe_inverse(n: float) -> Maybe[float]: return just(1/n) if n != 0 else nothing()
just(4).bind(safe_inverse) # Returns Just(0.25) just(0).bind(safe_inverse) # Returns Nothing nothing().bind(safe_inverse) # Returns Nothing ```
-
get_or(default)
: Extracts the value from aJust
or returns thedefault
value if it'sNothing
. This is how you exit theMaybe
world.python just(100).get_or(0) # Returns 100 nothing().get_or(0) # Returns 0
-
to_optional()
: Converts theMaybe
to a standard PythonOptional
, returning the raw value orNone
.python just("value").to_optional() # Returns "value" nothing().to_optional() # Returns None
3.1. Creating Maybes From Optionals
The from_optional
helper function provides a clean way to convert standard
Python None
-based logic into the Maybe
type.
from talvez import from_optional
# A function that might return None
def find_user(user_id: int) -> str | None:
if user_id == 1:
return "Alice"
return None
maybe_user = from_optional(find_user(1)) # Just('Alice')
maybe_nobody = from_optional(find_user(2)) # Nothing
3.2. Sequencing a Collection of Maybes
The sequence
function is used to convert an iterator of Maybe
s into a single
Maybe
of a list. It short-circuits, meaning if any element in the collection
is Nothing
, the entire result is Nothing
. This is useful for validating
multiple inputs at once.
from talvez import sequence, just, nothing
# All succeed
maybes1 = iter([just(1), just(2), just(3)])
result1 = sequence(maybes1)
assert result1.get_or([]) == [1, 2, 3]
# One fails
maybes2 = iter([just(1), nothing(), just(3)])
result2 = sequence(maybes2)
assert result2.is_nothing is True
4. Wrapping Existing Functions With Decorators
Decorators are the most ergonomic way to integrate talvez
into an existing
codebase. They automatically wrap a function's execution, handling exceptions
and validation for you.
4.1. @maybe
@maybe
converts a function that could raise an exception or return an invalid
result into one that safely returns a Maybe
.
Behavior:
- Catches any exception: If the wrapped function raises any
Exception
,@maybe
catches it and returnsNothing
. - Enforces a predicate (
ensure
): You can provide a function to theensure
argument. After the original function executes successfully, its result is passed to theensure
function. If it doesn't returnTrue
, the result is discarded andNothing
is returned. - Handles warnings: By default, warnings are ignored. If you set
allow_warning=False
, any warning emitted during the function's execution will be treated as a failure, causing it to returnNothing
.
Basic Example (Exception Handling):
from talvez import maybe
@maybe()
def parse_int(x: str) -> int:
return int(x)
# Before: parse_int("xyz") would raise a ValueError.
# After: It safely returns Nothing.
assert parse_int("10").get_or(0) == 10
assert parse_int("xyz").is_nothing is True
With a Predicate:
The ensure
predicate validates the successful return value.
# We want a function that only returns positive numbers
@maybe(ensure=lambda v: v > 0)
def parse_positive_int(x: str) -> int:
return int(x)
assert parse_positive_int("123").get_or(None) == 123
assert parse_positive_int("-5").is_nothing # ensure failed
assert parse_positive_int("abc").is_nothing # int() raised an exception
Handling Warnings:
import warnings
@maybe(allow_warning=False)
def risky_operation():
warnings.warn("This is a deprecated function")
return 1
# The warning is caught and treated as a failure
assert risky_operation().is_nothing is True
@maybe(allow_warning=True) # The default behavior
def less_risky_operation():
warnings.warn("This can be ignored")
return 1
assert less_risky_operation().get_or(None) == 1
4.2. @perhaps
@perhaps(default=...)
is a convenient alternative to @maybe
. It behaves
identically in terms of catching exceptions and validating results, but instead
of returning a Maybe
wrapper, it returns the raw value on success and a
specified default
value on any failure.
Use @perhaps
when you want to immediately fall back to a default value rather
than carrying the Maybe
context forward.
from talvez import perhaps
@perhaps(default=0.0)
def safe_div(a, b):
return a / b
result1 = safe_div(10, 2) # 5.0
result2 = safe_div(10, 0) # 0.0 (fallback on ZeroDivisionError)
# Using @maybe would require an extra step:
# @maybe()
# def maybe_div(a,b): return a/b
# result = maybe_div(10,0).get_or(0.0)
5. Predicates and Composition
Predicates are simple functions that take a value and return True
or False
.
They are used with the ensure
argument in decorators to perform validation.
talvez
provides several common predicates out of the box.
not_null
: Checksvalue is not None
.not_nan
: Checks that a float is notNaN
.not_infinite
: Checks that a number is notinf
or-inf
.not_undefined
: A combination ofnot_null
,not_nan
, andnot_infinite
.not_empty
: For sized objects (like strings, lists), checkslen(value) > 0
. For non-sized objects, it passes.
You can combine these predicates using and_
and or_
to build more complex
validation logic.
and_(*preds)
: Creates a new predicate that succeeds only if all child predicates returnTrue
.or_(*preds)
: Creates a new predicate that succeeds if at least one child predicate returnsTrue
.
Example:
Let's create a validator for a user profile name, which must exist and not be empty whitespace.
from talvez import maybe, not_null, not_empty, and_
# This predicate ensures the name is not None and not an empty string.
is_valid_name = and_(not_null, not_empty)
@maybe(ensure=is_valid_name)
def build_name(first: str, last: str) -> str:
# We strip whitespace before validation, but if the string is empty
# after stripping, not_empty will catch it.
full_name = f"{first.strip()} {last.strip()}"
return full_name if full_name.strip() else ""
assert build_name("Ada", "Lovelace").get_or(None) == "Ada Lovelace"
assert build_name(" ", "Lovelace").is_nothing # Fails not_empty after strip
assert build_name("Grace", "").is_nothing # Fails not_empty after strip
6. Chaining and Pipelines
The true power of Maybe
emerges when you compose multiple fallible operations.
If any step in the chain fails, the entire subsequent chain is skipped, and
Nothing
is propagated to the end.
6.1. Manual Chaining With bind
You can manually chain operations using the bind
method. This is explicit and
very readable for simple pipelines.
from talvez import just, maybe
@maybe()
def parse(s: str) -> int:
return int(s.strip())
@maybe(ensure=lambda i: i > 0)
def ensure_positive(i: int) -> int:
return i
result = (just(" 42 ")
.bind(parse) # Becomes Just(42)
.bind(ensure_positive) # Still Just(42)
.fmap(lambda i: i * 2)) # Becomes Just(84)
assert result.get_or(None) == 84
# A failing example
failing_result = (just(" -10 ")
.bind(parse) # Becomes Just(-10)
.bind(ensure_positive) # Becomes Nothing here
.fmap(lambda i: i * 2)) # This step is skipped
assert failing_result.is_nothing is True
6.2. chain
Utility
The chain
utility simplifies the process of applying a sequence of functions
that each return a Maybe
. It is syntactic sugar for a series of bind
calls.
from talvez import maybe, just, chain
@maybe()
def step1(x: int): return x + 10
@maybe()
def step2(x: int): return x * 3
@maybe(ensure=lambda v: v < 50)
def step3_with_validation(x: int): return x + 5
# Success case
res_ok = chain(just(5), step1, step2, step3_with_validation) # ((5+10)*3)+5 = 50. Fails validation.
# Let's adjust step3 to succeed
@maybe(ensure=lambda v: v <= 50)
def step3_fixed(x: int): return x + 5
res_ok_fixed = chain(just(5), step1, step2, step3_fixed) # ((5+10)*3)+5 = 50. Passes validation.
assert res_ok.is_nothing is True
assert res_ok_fixed.get_or(None) == 50
# Failure case
res_fail = chain(just(10), step1, step2, step3_with_validation) # ((10+10)*3)+5 = 65. Fails validation at step3.
assert res_fail.is_nothing is True
6.3. compose_maybe
for Reusable Pipelines
If you have a pipeline that you need to reuse, compose_maybe
lets you define
it once as a single function.
from talvez import compose_maybe
# Using the same step functions from the previous example
pipeline = compose_maybe(step1, step2, step3_fixed)
# Now 'pipeline' is a function that takes a Maybe and runs it through the steps.
assert pipeline(just(5)).get_or(None) == 50
assert pipeline(just(10)).is_nothing is True # Fails at step3
assert pipeline(nothing()).is_nothing is True # Starts with Nothing, remains Nothing
7. Error vs. Failure Semantics
talvez
standardizes how failures are handled, making your code's behavior
predictable.
Scenario | @maybe outcome |
@perhaps(default=D) outcome |
---|---|---|
Function executes successfully | Just(value) |
value |
Function raises an exception | Nothing |
D (default value) |
ensure predicate returns False |
Nothing |
D (default value) |
Warning emitted & allow_warning=False |
Nothing |
D (default value) |
This uniform table simplifies reasoning about the reliability and output of any
function decorated with talvez
.
8. Interoperability & Migration
8.1. Gradual Adoption
You don't need to rewrite your entire application to use talvez
. The best
approach is to start at the "edges" of your system—places where your code
interacts with the messy outside world.
- Parsing / Validation: Wrap functions that parse user input, config files, or API responses.
- External I/O: Wrap functions that read files, make network requests, or query a database. These can all fail for reasons beyond your control.
- Optional Configuration: Instead of
config.get('key')
which returnsNone
, wrap it to return aMaybe
.
Once the inputs to a system are safely wrapped in Maybe
, you can propagate that safety inward as needed.
8.2. Converting Back to Exceptions (If Needed)
Sometimes you need to interact with a library or framework that expects exceptions. It's easy to exit the "Maybe world" and raise an exception.
@maybe()
def parse_int(x: str) -> int:
return int(x)
raw_input = "x"
parsed_value = parse_int(raw_input)
if parsed_value.is_nothing:
raise ValueError(f"Invalid integer provided: {raw_input}")
# Now you can safely use the value
use_value(parsed_value.get_or(0)) # Or just parsed_value.value
8.3. Using With Type Checkers
Type checkers like Mypy understand Maybe
because it's defined as a Union
.
This allows you to get static analysis benefits. After you check is_just
, the
type checker knows the value is present.
m = parse_int("12") # Type of m is Maybe[int]
if m.is_just:
# Inside this block, the type checker knows `m` is Just[int]
# and that `m.value` exists and is an `int`.
print(m.value + 10) # No type error
9. Advanced Patterns
9.1. Lifting Multi-Arg Functions
Decorators work best on functions that will be the start of a chain
. If you
have a multi-argument function you want to use mid-pipeline, wrap it first and
then use functools.partial
or a lambda
to supply the other arguments.
from functools import partial
from talvez import maybe, just, chain
@maybe()
def div(a: int, b: int) -> float:
return a / b
# We want a pipeline that takes a number and divides it by 2.
# We can't pass `div` directly to `chain` because it needs two arguments.
# Option 1: Lambda
pipeline1 = chain(just(20), lambda x: div(x, 2))
assert pipeline1.get_or(None) == 10.0
# Option 2: functools.partial
safe_div_by_2 = partial(div, b=2)
# Whoops, partial doesn't work that way. We need to flip the arguments.
safe_div_by = lambda numerator, denominator: div(numerator, denominator)
pipeline2 = chain(just(20), lambda x: safe_div_by(x, 2))
assert pipeline2.get_or(None) == 10.0
9.2. Conditional Branching
You can use bind
with a lambda
to introduce conditional logic into a
pipeline. If a condition isn't met, you can switch the pipeline to the Nothing
track.
# A pipeline to process even numbers under 20
result = (just(18)
.fmap(lambda v: v - 4) # Becomes Just(14)
.bind(lambda v: just(v) if v % 2 == 0 else nothing()) # Condition passes
.fmap(lambda v: v * 10)) # Becomes Just(140)
assert result.get_or(None) == 140
# A failing case
result_fail = (just(17)
.fmap(lambda v: v - 4) # Becomes Just(13)
.bind(lambda v: just(v) if v % 2 == 0 else nothing()) # Condition fails, becomes Nothing
.fmap(lambda v: v * 10)) # Skipped
assert result_fail.is_nothing is True
9.3. Aggregating Independent Maybes
The sequence
function is the canonical way to handle this. If you have
multiple independent Maybe
values and you need them all to be Just
to
proceed, sequence
is the tool.
from talvez import sequence
@maybe()
def get_user_id(username: str) -> int:
return {"alice": 1, "bob": 2}.get(username)
# Get multiple inputs
user_ids = [get_user_id("alice"), get_user_id("bob")] # [Just(1), Just(2)]
all_good = sequence(iter(user_ids))
assert all_good.get_or(None) == [1, 2]
# One input fails
bad_user_ids = [get_user_id("alice"), get_user_id("charlie")] # [Just(1), Nothing]
one_failed = sequence(iter(bad_user_ids))
assert one_failed.is_nothing is True
9.4. Decorating Methods
The decorators work on instance methods and class methods just as they do on
regular functions. The self
or cls
argument is handled correctly.
class Calculator:
def __init__(self, allow_zero=False):
self.allow_zero = allow_zero
@maybe()
def inverse(self, x: float) -> float:
if x == 0 and not self.allow_zero:
raise ValueError("Zero is not allowed")
return 1 / x
calc = Calculator()
assert calc.inverse(4).get_or(None) == 0.25
assert calc.inverse(0).is_nothing is True
10. Testing Strategies
When testing functions that return Maybe
, you should test both the success and
failure paths explicitly.
- Assert the structure: For a successful input, assert the result
is_just
. For a failing input, assert itis_nothing
. This is more robust than only checking the unwrapped value. - Assert the value: For a
Just
result, you can then check its contents usingget_or
or by accessing.value
after anis_just
check. - Use property tests: For functional types like
Maybe
, property-based testing is very effective. For example, for any valuex
,just(x).fmap(lambda y: y)
should always equaljust(x)
.
Example Test Case:
from talvez import just
# Assuming parse_positive_int from a previous example
def test_parse_positive_int_success():
result = parse_positive_int("100")
assert result.is_just
assert result.get_or(0) == 100
def test_parse_positive_int_failure_on_negative():
result = parse_positive_int("-5")
assert result.is_nothing
def test_parse_positive_int_failure_on_invalid_string():
result = parse_positive_int("not a number")
assert result.is_nothing
def test_fmap_identity_law():
# Property: mapping the identity function over a Just should not change it.
j = just(5)
assert j.fmap(lambda x: x) == j
11. Performance Notes
- Minimal Overhead: The overhead of wrapping a value in a
Just
dataclass or creating aNothing
singleton is very small. The logic inside the decorators is also lightweight. - Best Use Cases: The library is ideal for I/O-bound operations, data processing pipelines, and request-level logic where the clarity and safety gains far outweigh the minuscule performance cost.
- Hot Loops: Avoid using
Maybe
wrappers inside tight, performance-critical loops where millions of operations are performed per second. In those scenarios, traditional error handling or raw primitives might be faster.
If profiling ever indicates a bottleneck, you can make targeted optimizations:
* Inline simple predicate checks instead of using the ensure
argument.
* Use sequence
to validate a list of items in one go rather than in a Python loop.
12. Comparison With Alternatives
Approach | Pros | Cons |
---|---|---|
Exceptions | Native to Python, provides a full stack trace. | Can be verbose (try/except ), encourages non-local control flow, makes function signatures lie. |
Returning None |
Simple and common in Python. | Ambiguous: Does None mean failure or a valid result? Requires constant if x is not None checks. |
Sentinel Objects | Explicit about failure. | Requires defining custom sentinel objects, adds boilerplate, not easily composable. |
talvez Maybe |
Explicit, composable, clean pipelines, safe. | Introduces a new (but simple) concept, requires a small library dependency. |
13. Common Pitfalls
Pitfall | Explanation | Fix |
---|---|---|
Forgetting ensure predicate semantics |
The ensure function must return True for the pipeline to continue. Returning None or False will be treated as a failure. |
Ensure your predicate function always returns an explicit boolean: return value > 0 not just value > 0 . |
Nesting Maybes by mistake | Using .fmap() with a function that already returns a Maybe will result in a nested Maybe , like Just(Just(5)) . |
Use .bind() when your function returns a Maybe . bind automatically flattens the result. |
Using .value without checking |
Accessing the .value property on a Nothing instance will raise an AttributeError . The property only exists on Just . |
Always use .get_or(default) or guard the access with an if m.is_just: check. |
Applying chain to a raw value |
The chain function expects its first argument to be a Maybe . Passing a raw value like chain(5, ...) will fail. |
Always start your chain with a Maybe , e.g., chain(just(5), ...) . |
14. Roadmap Ideas
- Async-aware decorators: Create
@maybe_async
that can wrap and await coroutine functions. - Mypy Plugin: A plugin could potentially improve type narrowing and inference for more complex pipeline scenarios.
- Additional Helpers: Add more functional combinators like
lift
to adapt multi-argument functions to work onMaybe
types seamlessly. - Integration with Validation Libraries: Create adapters for popular
libraries like Pydantic to bridge their validation failures into the
Maybe
ecosystem.
15. Quick Reference
Here is a small, complete example showcasing the core features.
from talvez import just, maybe, chain
# Step 1: Parse a string, but only if it's a digit.
@maybe(ensure=str.isdigit)
def get_digit_str(s: str) -> str:
return s.strip()
# Step 2: Convert to integer.
@maybe()
def to_int(s: str) -> int:
return int(s)
# Step 3: Increment the integer, but only if it's positive.
@maybe(ensure=lambda v: v > 0)
def inc_positive(x: int) -> int:
return x + 1
# Chain the operations together starting with a raw value
result = chain(just(" 41 "), get_digit_str, to_int, inc_positive)
assert result.get_or(None) == 42
# A failing example
failing_result = chain(just(" -5 "), get_digit_str, to_int, inc_positive)
assert failing_result.is_nothing is True
16. Closing Thoughts
talvez
offers a pragmatic middle ground between Python's traditional error
handling and the all-in approach of purely functional languages. It provides
functional-style safety without heavy abstractions or a steep learning curve.
The best way to get started is to find a small, risky part of your codebase—like
parsing an API response—and wrap it with @maybe
. See how it cleans up your
logic, and grow the pattern organically from there.
Feedback and contributions are welcome.