typeric is a practical type utility toolkit for Python, focused on clarity, safety, and ergonomics. It was originally built to make my own development experience smoother, but I hope it proves useful to others as well.
It currently provides lightweight, pattern-matchable types like Result and Option β inspired by Rust β with plans to include more common type patterns and error-handling abstractions.
pip install typeric-
π― Rust-style error propagation (the
?operator)
.spread()+@spreadableshort-circuits a function on the firstErr/NONE, exactly like Rust's?β the core of this library. Works for bothResultandOption, sync and async. -
β Functional-style
Resulttype
Ok(value)andErr(error)with powerful.map(),.and_then(),.combine(),.spread()helpers β inspired by RustβsResult. -
π Lightweight
Optiontype
Some(value)andNONEto handle nullable data safely, with.map(),.unwrap_or(),.is_some()and more. -
π Seamless conversion decorators
@resulty: Wraps any function to returnResultinstead of raising exceptions.@resulty(catch=...): Catch only specific exception types and keep the exception object as the error.@optiony: Wraps any function to returnOption, convertingNoneor exceptions intoNONE.@optiony(catch=...): Only the listed exception types becomeNONE; everything else propagates.
-
π
ResultβOptioninterop
result.to_option(),option.ok_or(error)/option.ok_or_else(f)β bridge the two worlds and keep one propagation chain. -
π Collection helpers
collect_results,partition_results,gather_results(async),combine_results(flat combine),collect_optionsβ batch up fallible operations without hand-written loops. -
π Static narrowing guards
is_ok/is_err/is_some/is_noneareTypeIsguards: afterif is_ok(res):, type checkers knowresisOk[T]. -
π§° Resilience decorators
@retry(retries onError listed exceptions with backoff),@timeout_async(expiry becomesErr(TimeoutError)),@cache_ok(memoizes only successes β failures are never cached),@log_err(failure branches logged automatically). All propagation-safe. -
π§© Pattern matching support
Supports Pythonβsmatchsyntax via__match_args__for bothOk/ErrandSome/NONE. -
π Immutable and composable
Safe and clean method chains using.map(),.combine(),.inspect(), etc. -
π§ Clean type signatures
Fully typed:Result[T, E]andOption[T]with static analysis and IDE support. -
π οΈ Extensible foundation
Designed for easy extension β more algebraic types (Either,Validated, etc.) can be added naturally.
The essence of Rust's error handling is not Result itself, but the ? operator:
fail fast, propagate the error, keep the happy path flat. typeric brings this to
Python with .spread() and @spreadable:
from typeric import Ok, Err, Result, spreadable
def find_user(uid: int) -> Result[str, str]:
return Ok("alice") if uid == 1 else Err("user not found")
def check_permission(user: str) -> Result[str, str]:
return Ok(user) if user == "alice" else Err("permission denied")
@spreadable
def handle_request(uid: int) -> Result[str, str]:
user = find_user(uid).spread() # like `find_user(uid)?` in Rust
granted = check_permission(user).spread()
return Ok(f"hello, {granted}")
handle_request(1) # Ok('hello, alice')
handle_request(42) # Err('user not found') β propagated, no if/else ladderOption propagates the same way with @spreadable_option:
from typeric import Some, NONE, Option, spreadable_option
def maybe_positive(x: int) -> Option[int]:
return Some(x) if x > 0 else NONE
@spreadable_option
def add_one(x: int) -> Option[int]:
val = maybe_positive(x).spread() # NONE short-circuits the whole function
return Some(val + 1)
add_one(5) # Some(6)
add_one(-1) # NONEAsync versions: @spreadable_async and @spreadable_option_async.
from typeric import Result, Ok, Err, resulty, resulty_async, spreadable
def parse_number(text: str) -> Result[int, str]:
try:
return Ok(int(text))
except ValueError:
return Err("Not a number")
match parse_number("42"):
case Ok(value):
print("Parsed:", value)
case Err(error):
print("Failed:", error)
# let function return Result[T,str]
@resulty
def add(x: int, y: int) -> int:
return x + y
# or catch only specific exceptions and keep the exception object
@resulty(catch=(ValueError, KeyError))
def parse_strict(raw: str) -> int: # -> Result[int, ValueError | KeyError]
return int(raw)
res = add(1, 2)
if res.is_ok():
print("Result:", res.unwrap())
else:
print("Error:", res.err)
# let async function return Result[T,str]
@resulty_async
async def async_add(x: int, y: int) -> int:
return x + y
res = await async_add(1, 2)
if res.is_ok():
print("Result:", res.unwrap())
else:
print("Error:", res.err)
def func_a(x: int) -> Result[int, str]:
if x < 0:
return Err("negative input")
return Ok(x * 2)
@spreadable
def func_b(y: int) -> Result[int, str]:
a = func_a(y).spread()
return Ok(a + 1)
def test_func_b_success():
assert func_b(5) == Ok(11) # 5*2=10 +1=11
def test_func_b_propagate_error():
assert func_b(-2) == Err("negative input")
def validate_username(username: str) -> Result[str, str]:
if username.strip():
return Ok(username)
return Err("Username is empty")
def validate_age(age: int) -> Result[int, str]:
if age > 0:
return Ok(age)
return Err("Age must > 0")
def validate_email(email: str) -> Result[str, str]:
if "@" in email:
return Ok(email)
return Err("Invalid email")
# β
results combine
def validate_user_data(
username: str, age: int, email: str
) -> Result[tuple[tuple[str, int], str], str]:
return (
validate_username(username)
.combine(validate_age(age))
.combine(validate_email(email))
)
result1 = validate_user_data("alice", 30, "alice@example.com")
print(result1) # Ok((('alice', 30), 'alice@example.com'))
result2 = validate_user_data("", -5, "invalid-email")
print(result2.errs) # ['Username is empty', 'Age must > 0', 'Invalid email']from typeric import Option, Some, NONE, NoneType, optiony, optiony_async
from typeric.wrap_funcs import get_time_sync
def maybe_get(index: int, items: list[str]) -> Option[str]:
if 0 <= index < len(items):
return Some(items[index])
return NONE
match maybe_get(1, ["a", "b", "c"]):
case Some(value):
print("Got:", value)
case NoneType(): # note: `case NONE:` would be a capture pattern matching anything
print("Nothing found")
@get_time_sync # This decorator is used for synchronous functions to measure execution time.
@optiony
def get_number(x: int) -> int | None:
if x > 0:
return x
return None
@optiony_async
async def fetch_data(flag: bool) -> str | None:
if flag:
return "data"
return NoneEverything is available from the top-level package:
from typeric import (
# Result
Result, Ok, Err,
# Option
Option, Some, NONE, NoneType,
# conversion decorators
resulty, resulty_async, optiony, optiony_async,
# propagation decorators (Rust's `?`)
spreadable, spreadable_async, spreadable_option, spreadable_option_async,
# collection helpers
collect_results, partition_results, gather_results, combine_results, collect_options,
# TypeIs narrowing guards
is_ok, is_err, is_some, is_none,
# resilience decorators
retry, retry_async, timeout_async, cache_ok, cache_ok_async, log_err, log_err_async,
# errors & helpers
UnwrapError, NoneTypeError, EarlyReturn, AggregatedErrors,
)Module paths also work: typeric.result, typeric.option, typeric.wrap_funcs.
| Method | Ok(v) |
Err(e) |
|---|---|---|
.is_ok() / .is_err() |
True / False |
False / True |
.ok |
v |
β |
.err |
β | e |
.errs |
None |
all aggregated errors as list[E] |
.unwrap() |
v |
raises e if it is an exception, else UnwrapError |
.unwrap_or(default) |
v |
default |
.unwrap_or_else(f) |
v |
f(e) |
.unwrap_err() |
raises UnwrapError |
e |
.expect(msg) |
v |
raises UnwrapError(f"{msg}: ...") |
.map(f) |
Ok(f(v)) |
unchanged |
.map_err(f) |
unchanged | Err(f(e)), mapping all aggregated errors |
.map_or(default, f) |
f(v) |
default |
.and_then(f) |
f(v) (returns a Result) |
unchanged |
.or_else(f) |
unchanged | f(e) (returns a Result) |
.flatten() |
Ok(Ok(v)) β Ok(v) |
unchanged |
.inspect(f) / .inspect_err(f) |
side effect on v / no-op |
no-op / side effect on e |
.combine(other) |
pair up values: Ok((v, other_v)) |
accumulate errors into .errs |
.to_option() |
Some(v) |
NONE |
.spread() |
v |
short-circuit: propagate to nearest @spreadable |
Notes:
- Values are immutable β
combine,map_err, etc. always return new instances and never mutate the operands, soResultvalues can be safely reused and shared. Ok/Err(andSome/NONE) are hashable and usable in sets/dicts as long as the wrapped value is hashable.combineaccumulates: chain it across validations, then read every failure from.errs(first failure stays in.err).
NONE is the singleton NoneType() instance.
| Method | Some(v) |
NONE |
|---|---|---|
.is_some() / .is_none() |
True / False |
False / True |
.unwrap() |
v |
raises NoneTypeError |
.unwrap_or(default) |
v |
default |
.unwrap_or_else(f) |
v |
f() |
.expect(msg) |
v |
raises NoneTypeError(msg) |
.map(f) |
Some(f(v)) |
NONE |
.and_then(f) |
f(v) (returns an Option) |
NONE |
.filter(pred) |
Some(v) if pred(v) else NONE |
NONE |
.zip(other) |
Some((v, other_v)) if both Some |
NONE |
.ok_or(error) / .ok_or_else(f) |
Ok(v) |
Err(error) / Err(f()) |
.spread() |
v |
short-circuit: propagate to nearest @spreadable_option |
Pattern matching: use case Some(value): and case NoneType():.
(case NONE: is a capture pattern in Python β it matches anything and rebinds the name.)
| Decorator | Applies to | Behavior |
|---|---|---|
@resulty |
sync def |
returns Ok(value); raised exceptions become Err(str(e)); a returned Result passes through with errors stringified |
@resulty(catch=T | (T, ...)) |
sync def |
catches only the listed exception types and keeps the exception object: Result[T, ValueError]; other exceptions propagate; returned Results pass through untouched |
@resulty_async / @resulty_async(catch=...) |
async def |
same as the sync versions |
@optiony |
sync def |
returns Some(value); None or a raised exception becomes NONE; a returned Option passes through |
@optiony(catch=T | (T, ...)) |
sync def |
only the listed exception types become NONE; other exceptions propagate |
@optiony_async / @optiony_async(catch=...) |
async def |
same as the sync versions |
@spreadable |
sync def returning Result |
catches .spread() short-circuits and returns them as Err |
@spreadable_async |
async def returning Result |
same as @spreadable |
@spreadable_option |
sync def returning Option |
catches .spread() short-circuits and returns NONE |
@spreadable_option_async |
async def returning Option |
same as @spreadable_option |
Propagation rules:
.spread()works by raising an internalEarlyReturnexception, caught by the nearestspreadable*wrapper β the function using.spread()must be decorated, otherwiseEarlyReturnescapes to the caller.@resulty/@optionydeliberately letEarlyReturnpass through, so they stack safely with the propagation decorators:
@spreadable # catches the propagation
@resulty # converts raised exceptions / bare returns
def handler(uid: int) -> int:
user = find_user(uid).spread() # Err propagates out past @resulty, intact
return len(user)from typeric import (
collect_results, partition_results, gather_results, combine_results, collect_options,
)
results = [parse(x) for x in ["1", "2", "oops"]]
collect_results(results) # Result[list[T], E] β fail-fast, first Err wins
partition_results(results) # (list[T], list[E]) β run everything, split values/errors
await gather_results(fetch(1), fetch(2)) # run coroutines concurrently, then collect
# flat combine: no nested tuples, errors aggregate into .errs
combine_results(Ok("alice"), Ok(30), Ok("a@b.c")) # Ok(('alice', 30, 'a@b.c'))
combine_results(Ok("alice"), Err("bad age"), Err("bad email")).errs
# ['bad age', 'bad email']
collect_options([Some(1), Some(2)]) # Some([1, 2]); any NONE -> NONEres.is_ok() returns a plain bool, which type checkers cannot narrow on.
The module-level guards can:
from typeric import is_ok
res: Result[int, str] = parse("42")
if is_ok(res):
reveal_type(res) # Ok[int] β .ok / .unwrap() fully typed
else:
reveal_type(res) # Err[str]Also available: is_err, is_some, is_none.
parse("42").to_option() # Result -> Option: Ok -> Some, Err -> NONE
maybe_user.ok_or("not found") # Option -> Result: Some -> Ok, NONE -> Err("not found")
@spreadable
def load_config(path: str) -> Result[str, str]:
raw = read_file(path).spread() # Result chain
key = find_key(raw).ok_or("key missing").spread() # Option joins the same chain
return Ok(key)| Decorator | Applies to | Behavior |
|---|---|---|
@retry(times=3, on=(), backoff=0.0, factor=2.0) |
sync def |
re-calls while the function returns Err; by default exceptions are not retried (they propagate immediately) β opt in with on=(ConnectionError, ...) for failures known to be transient. Sleeps backoff * factor**n between attempts; the final Err is returned / final exception re-raised unchanged. NONE is a value, not a failure β never retried |
@retry_async(...) |
async def |
same, sleeping with asyncio.sleep |
@timeout_async(seconds) |
async def |
deadline expiry returns Err(TimeoutError); a TimeoutError raised by the function body itself (e.g. an OS-level socket timeout) is not the deadline and propagates. Result/Option returns pass through untouched, bare values become Ok(value) |
@cache_ok(maxsize=128) |
sync def |
LRU-memoizes only Ok/Some results; Err, NONE, and exceptions are never cached, so transient failures retry naturally. Like lru_cache, hits return the same instance β treat cached values as immutable. Exposes .cache_clear() |
@cache_ok_async(maxsize=128) |
async def |
same (concurrent misses may compute twice) |
@log_err / @log_err(level=...) |
sync def |
logs Err/NONE returns on the "typeric" logger, returns them unchanged; Ok/Some stay silent |
@log_err_async |
async def |
same |
All of them let EarlyReturn pass through, so .spread() propagation keeps
working inside a retried/cached/logged function.
from typeric import Ok, Err, Result, retry_async, timeout_async, cache_ok_async, log_err_async
@cache_ok_async() # outermost: cache the final success
@log_err_async # log what failed after retries gave up
@retry_async(times=3, backoff=0.5) # retry transient failures
@timeout_async(2.0) # innermost: each attempt is bounded
async def fetch_profile(uid: int) -> Result[dict, str]:
...Recommended stacking order: cache_ok outermost, then log_err, then
retry, with timeout_async innermost so every retry attempt gets its own
time budget.
@get_time_sync / @get_time_async log call arguments and execution time via the
standard-library logger named "typeric" (no third-party dependency). Enable output with:
import logging
logging.basicConfig(level=logging.INFO)Run tests with:
uv run pytest -v- Async support (
resulty_async,optiony_async,spreadable_async,spreadable_option_async) - Rust-style
?propagation for bothResultandOption -
ResultβOptioninterop (to_option,ok_or,ok_or_else) - Collection helpers (
collect_results,partition_results,gather_results,combine_results,collect_options) - Exception-preserving decorators (
@resulty(catch=...),@optiony(catch=...)) -
TypeIsnarrowing guards (is_ok,is_err,is_some,is_none) - Resilience decorators (
retry,timeout_async,cache_ok,log_err) -
Try,Either,NonEmptyList, etc.
MIT