corrode
A Rust-like Result type for Python 3.11+, fully type annotated.
📚 Documentation — the full API
reference is generated from the source docstrings, so it always matches the
code, and every example in it is executed as a doctest in CI. The site is
versioned: it opens on latest (the newest release), every past release stays
available, and dev tracks unreleased main. Pick one in the header.
Table of Contents
- Installation
- Why
- Exhaustive error handling
- Guarantees
- Tour
- Iterator utilities
- Async iterator utilities
- Adopting corrode in an existing codebase
- Typing
- License
Installation
uv add corrode
or with pip / poetry:
pip install corrode
poetry add corrode
Why
Exceptions are implicit. Nothing in a function signature tells you it can
raise, what it raises, or whether the caller remembered to handle it.
Bugs hide until production, and except Exception becomes the norm:
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
# Can this raise? What exceptions? The signature doesn't tell you.
def get_user(user_id: int) -> User:
if user_id <= 0:
raise ValueError(f"Invalid user ID: {user_id}")
if user_id == 13:
raise PermissionError("Access denied")
return User(id=user_id, name="Alice")
# The caller has no idea this can fail — until it does in production
user = get_user(1)
assert user.name == "Alice"
Result[T, E] is a union of Ok[T] | Err[E]:
from dataclasses import dataclass
from corrode import Result, Ok, Err
@dataclass
class User:
id: int
name: str
@dataclass
class NotFound:
user_id: int
@dataclass
class Forbidden:
reason: str
type GetUserError = NotFound | Forbidden
# Errors are now part of the return type — callers see exactly what can go wrong
def get_user(user_id: int) -> Result[User, GetUserError]:
# Instead of raise, return Err — the type checker tracks it
if user_id <= 0:
return Err(NotFound(user_id=user_id))
if user_id == 13:
return Err(Forbidden(reason="banned"))
return Ok(User(id=user_id, name="Alice"))
# Can't ignore errors — Result forces you to handle both variants
assert get_user(1) == Ok(User(id=1, name="Alice"))
assert get_user(-1) == Err(NotFound(user_id=-1))
Exhaustive error handling
from dataclasses import dataclass
from typing import assert_never
from corrode import Ok, Err, Result
@dataclass
class User:
id: int
name: str
@dataclass
class NotFound:
user_id: int
@dataclass
class Forbidden:
reason: str
type GetUserError = NotFound | Forbidden
def get_user(user_id: int) -> Result[User, GetUserError]:
if user_id <= 0:
return Err(NotFound(user_id=user_id))
if user_id == 13:
return Err(Forbidden(reason="banned"))
return Ok(User(id=user_id, name="Alice"))
match get_user(42):
case Ok(user):
print(f"Welcome, {user.name}")
case Err(e):
# Nested match on the error — each variant handled explicitly
match e:
case NotFound(user_id=uid):
print(f"User {uid} does not exist")
case Forbidden(reason=reason):
print(f"Access denied: {reason}")
case _:
# If you add a new variant to GetUserError, mypy reports
# an error here until you handle it — compile-time safety
assert_never(e)
Guarantees
Ok and Err are strict containers:
- Immutable. The contained value cannot be reassigned or deleted after
construction (
AttributeError). Instances are safe to share and to use as dict keys or set members (hashable when the contained value is hashable). - No truth value.
if result:is the classic silent bug — anyResultwould be truthy, so a failure passes the check.corrodemakes it loud at runtime, and because__bool__is typed asNoReturn, a type checker reports the body ofif result:as unreachable before you ever run it:
from corrode import Result, Ok, Err
result: Result[int, str] = Err("hidden failure")
try:
bool(result) # this is what `if result:` does under the hood
except TypeError as e:
print(e) # Ok and Err have no truth value; use is_ok()/is_err(), ...
Use is_ok() / is_err(), pattern matching, or is_ok_and() instead.
- Not general-purpose iterables. Iterating an
Erroutsidedo()notation (e.g.list(Err(...))) raisesDoErrorwith an explanatory message instead of silently misbehaving. BaseExceptionis never swallowed.@as_result/@as_async_resultaccept onlyExceptionsubclasses —KeyboardInterrupt,SystemExitandasyncio.CancelledErroralways propagate, so task cancellation keeps working.- Async utilities never leak tasks, never lose exceptions. Raised
exceptions always arrive as a single
ExceptionGroup— even a lone one — so what you catch never depends on timing. See Exceptions andExceptionGroup.
Tour
A taste of the combinators. Every sync method has a doctested example in the
API reference; the _async
variants mirror their sync counterparts:
from dataclasses import dataclass
from corrode import Ok, Err, Result, from_optional
@dataclass
class User:
id: int
name: str
def find_user(user_id: int) -> Result[User, str]:
users = {1: User(id=1, name="Alice")}
# from_optional bridges the idiomatic `T | None` into Result
return from_optional(users.get(user_id), f"user {user_id} not found")
# map transforms the success value; Err passes through untouched
assert find_user(1).map(lambda u: u.name) == Ok("Alice")
assert find_user(2).map(lambda u: u.name) == Err("user 2 not found")
# and_then chains fallible steps; the first Err short-circuits
def check_admin(user: User) -> Result[User, str]:
return Ok(user) if user.id == 1 else Err("not an admin")
assert find_user(1).and_then(check_admin) == Ok(User(id=1, name="Alice"))
# or_else recovers from failures
assert find_user(2).or_else(lambda _: find_user(1)).map(lambda u: u.id) == Ok(1)
# zip combines independent results into a tuple (first Err wins)
assert Ok(1).zip(Ok("a"), Ok(3.0)) == Ok((1, "a", 3.0))
# flatten removes one level of nesting: Result[Result[T, E], E] -> Result[T, E]
assert Ok(Ok(1)).flatten() == Ok(1)
# unwrap_or extracts with a fallback when you leave Result-land
assert find_user(2).map(lambda u: u.name).unwrap_or("guest") == "guest"
Wrap exception-raising code at the boundary with @as_result /
@as_async_result:
import os
from corrode import as_result, Ok
os.environ["PORT"] = "8080"
# Raised KeyError / ValueError become Err(exc); other exceptions propagate
@as_result(KeyError, ValueError)
def parse_port(key: str) -> int:
return int(os.environ[key])
assert parse_port("PORT") == Ok(8080) # Result[int, KeyError | ValueError]
Also available — see the API reference:
- transforms:
map_err,map_or,map_or_else - predicates:
is_ok,is_err,is_ok_and,is_err_and - side effects:
inspect,inspect_err - extraction:
ok,err,ok_value,err_value,unwrap,expect,unwrap_or_else,unwrap_or_raise _asyncvariants of every combinator that takes a callbackdo()notation — deprecated: the annotation it requires is not checked by type checkers, which defeats the purpose; calling it emits aDeprecationWarning
Iterator utilities
corrode.iterator works with iterables of Result values
(API reference):
| Function | Semantics |
|---|---|
collect |
all values, or the first error (short-circuits) |
collect_all |
all values, or all errors (never short-circuits) |
map_collect |
collect with the mapping inline |
partition |
split into (oks, errs), keep both sides |
map_partition |
partition with the mapping inline |
filter_ok |
lazily yield values, skip errors |
filter_err |
lazily yield errors, skip values |
try_reduce |
fold with a fallible function, short-circuit on the first error |
from corrode import Ok, Err, Result
from corrode.iterator import map_collect, collect_all, partition
def parse(s: str) -> Result[int, str]:
return Ok(int(s)) if s.isdigit() else Err(f"not a number: {s!r}")
# Fail fast: the first Err wins
assert map_collect(["1", "2", "3"], parse) == Ok([1, 2, 3])
assert map_collect(["1", "x", "3"], parse) == Err("not a number: 'x'")
# Accumulate: Ok only when everything succeeded, otherwise every error.
# The validation use case — the caller gets a complete error report.
assert collect_all([parse("1"), parse("x"), parse("y")]) == Err(
["not a number: 'x'", "not a number: 'y'"]
)
# Keep both sides
assert partition([parse("1"), parse("x"), parse("2")]) == ([1, 2], ["not a number: 'x'"])
Async iterator utilities
corrode.async_iterator runs coroutines or tasks concurrently
(API reference):
| Function | Semantics |
|---|---|
collect |
input order; first Err cancels the rest |
collect_all |
input order; all values or all errors, runs everything |
map_collect / map_partition |
collect / partition with the mapping inline |
partition |
input order; (oks, errs), runs everything |
filter_ok_unordered / filter_err_unordered |
yield in completion order, skip the other side |
filter_ok / filter_err |
yield in input order (explicit concurrency required) |
try_reduce |
sequential fold, short-circuit on Err |
Every function accepts concurrency to bound how many tasks run at once
(None = unlimited). All of them clean up after themselves: cancelling the
caller, breaking out of an async for, or an exception in any task cancels
all in-flight tasks and closes unconsumed coroutines — nothing keeps running
in the background.
import asyncio
from dataclasses import dataclass
from corrode import Ok, Err, Result
from corrode.async_iterator import map_collect, collect_all
@dataclass
class User:
id: int
async def fetch_user(user_id: int) -> Result[User, str]:
if user_id <= 0:
return Err(f"bad id: {user_id}")
return Ok(User(id=user_id))
async def main() -> None:
# Concurrent and bounded; results in input order; first Err cancels the rest
result = await map_collect([1, 2, 3, 4, 5], fetch_user, concurrency=3)
assert result == Ok([User(id=i) for i in range(1, 6)])
# Error accumulation: every failure is reported, nothing is cancelled
report = await collect_all([fetch_user(1), fetch_user(-1), fetch_user(-2)])
assert report == Err(["bad id: -1", "bad id: -2"])
asyncio.run(main())
Exceptions and ExceptionGroup
A raised exception (as opposed to a returned Err) means a bug or an
infrastructure failure — it propagates and cancels everything else. The
contract is uniform: exceptions from the concurrent utilities always arrive
wrapped in an ExceptionGroup, even when only one task failed. One failure
is a group of one.
This is deliberate. Whether one or several tasks fail "at the same time" is a
race — if a lone exception propagated bare, except ConnectionError would
work in testing and silently miss in production the day two requests fail in
the same event-loop tick. The exception type you catch must never depend on
timing, so there is exactly one thing to write: except* (the same rule
asyncio.TaskGroup follows). Tasks that raise while being cancelled are
collected into the group too — nothing is silently discarded.
The only exception is sequential try_reduce: it runs one coroutine at a
time, so only one can fail, and it propagates bare.
import asyncio
from corrode import Ok, Result
from corrode.async_iterator import collect
async def good() -> Result[int, str]:
return Ok(1)
async def fetch(source: str) -> Result[int, str]:
raise ConnectionError(f"{source} unreachable")
async def main() -> None:
# A single failure — still an ExceptionGroup, still except*
single: list[str] = []
try:
await collect([good(), fetch("eu")])
except* ConnectionError as group:
single = [str(e) for e in group.exceptions]
assert single == ["eu unreachable"]
# Several simultaneous failures — same handling, nothing is lost
several: list[str] = []
try:
await collect([fetch("eu"), fetch("us")])
except* ConnectionError as group:
several = sorted(str(e) for e in group.exceptions)
assert several == ["eu unreachable", "us unreachable"]
asyncio.run(main())
Adopting corrode in an existing codebase
You don't have to rewrite everything at once — corrode is designed for
gradual adoption:
- Wrap existing functions with
@as_result(ExcType, ...)— the body is unchanged, callers start receivingResultwith the exception insideErr. - Return
Err(exc)explicitly — replaceraisewithreturn Err(exc)and drop the decorator; the error types move into the signature, callers don't change. - Replace exceptions with domain types — frozen dataclasses carrying exactly the data the caller needs, no more parsing exception messages.
import os
from dataclasses import dataclass
from corrode import as_result, Ok, Err, Result
os.environ["PORT"] = "8080"
# Step 1: wrap — the body is untouched, callers get Result
@as_result(KeyError, ValueError)
def parse_port_wrapped(key: str) -> int:
return int(os.environ[key])
assert parse_port_wrapped("PORT") == Ok(8080)
# Step 3: domain error types instead of exceptions
@dataclass
class MissingKey:
key: str
@dataclass
class InvalidValue:
key: str
raw: str
def parse_port(key: str) -> Result[int, MissingKey | InvalidValue]:
raw = os.environ.get(key)
if raw is None:
return Err(MissingKey(key=key))
try:
return Ok(int(raw))
except ValueError:
return Err(InvalidValue(key=key, raw=raw))
assert parse_port("PORT") == Ok(8080)
assert parse_port("MISSING") == Err(MissingKey(key="MISSING"))
try/except and Result mix freely in the same function — catch what you
caught before and wrap it in Err.
Typing
corrode is fully typed and ships a py.typed marker (PEP 561) —
type information works out of the box, no stubs needed. Every release is
verified against four type checkers in strict mode: mypy, basedpyright,
ty, and pyrefly. All README examples are executed and type-checked in CI;
all docstring examples run as doctests.
Acknowledgements
corrode is inspired by and originally forked from rustedpy/result.
We are grateful for that library's existence — it laid the foundation for bringing Rust-style result types to Python and made this project possible.
License
MIT License
Release files for corrode 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| corrode-1.0.0.tar.gz | 133.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| corrode-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:154.9 kB
Release files / corrode-1.0.0.tar.gz
| Download URL | corrode-1.0.0.tar.gz |
|---|---|
| Size | 133.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6c1cef7528fc367138fc2231bead5b3c75f01bcd82be46ebc99a3c2ad8e5f1a1
|
|
BLAKE2b-256 checksum How to use checksums |
3086b8bc34ee97e72cf56b1670808310c7aced8d6e8dc691a6553fc8189c87cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 7, 2026.
Transparency logRelease files / corrode-1.0.0-py3-none-any.whl
| Download URL | corrode-1.0.0-py3-none-any.whl |
|---|---|
| Size | 21.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
18b3ede4b042a02a59cf174b12f3b5a584c209ce14b61a0b21f00f8b2c6fd8a9
|
|
BLAKE2b-256 checksum How to use checksums |
00277c40bb6225a98279f035df4990deed88e4f24b4ca4366b5bb0c001cb8a8a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 7, 2026.
Transparency log