Corrode
Type-safe error handling in Python
Install / Use
/learn @deliro/CorrodeREADME
corrode
A Rust-like Result type for Python 3.11+, fully type annotated.
</div>Explicit is better than implicit. Errors should never pass silently.
— The Zen of Python
Table of Contents
- Installation
- Why
- Quick start
- Exhaustive error handling
- Adopting corrode in an existing codebase
- API reference
- Iterator utilities
- Async iterator utilities
- 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 makes errors explicit, typed, and impossible to ignore:
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
# Now every caller sees the possible errors in the signature
def get_user(user_id: int) -> Result[User, NotFound | Forbidden]:
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"))
# Caller must handle the Result — can't accidentally ignore errors
assert get_user(1) == Ok(User(id=1, name="Alice"))
assert get_user(-1) == Err(NotFound(user_id=-1))
Now every caller sees the possible errors in the signature, the type checker verifies every branch is handled, and adding a new error variant is a compile-time breaking change — not a runtime surprise.
Quick start
Result[T, E] is a union of Ok[T] | Err[E]. Every Result must be explicitly
handled — no silent Nones, no uncaught exceptions.
from dataclasses import dataclass
from corrode import Ok, Err, Result
@dataclass
class User:
id: int
name: str
email: 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", email="alice@example.com"))
# Test it works
assert get_user(1) == Ok(User(id=1, name="Alice", email="alice@example.com"))
assert get_user(-1) == Err(NotFound(user_id=-1))
Exhaustive error handling
Use a nested match on the error value together with assert_never to get
a compile-time guarantee that every error variant is handled:
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):
match e:
case NotFound(user_id=uid):
print(f"User {uid} does not exist")
case Forbidden(reason=reason):
print(f"Access denied: {reason}")
case _:
assert_never(e)
Now add a new error variant — mypy immediately reports that the new case
is not handled, forcing you to update the code before it compiles:
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
@dataclass
class RateLimited:
retry_after: float
type GetUserError = NotFound | Forbidden | RateLimited
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"))
if user_id == 100:
return Err(RateLimited(retry_after=60.0))
return Ok(User(id=user_id, name="Alice"))
# Now we must handle all three error variants
match get_user(100):
case Ok(user):
print(f"Welcome, {user.name}")
case Err(e):
match e:
case NotFound(user_id=uid):
print(f"User {uid} does not exist")
case Forbidden(reason=reason):
print(f"Access denied: {reason}")
case RateLimited(retry_after=seconds):
print(f"Rate limited, retry after {seconds}s")
case _:
assert_never(e)
You are forced to handle the new case before the code passes type checking. No error silently slips through.
Adopting corrode in an existing codebase
You don't have to rewrite everything at once. Exceptions don't disappear
overnight, and third-party libraries will always raise them. That's fine —
corrode is designed for gradual adoption.
Step 1: wrap existing functions with @as_result
You have code that raises. Don't rewrite it yet — just wrap it:
import os
from corrode import as_result, Ok, Err
# Before: raises KeyError, ValueError, nobody knows about it
def parse_port_unsafe(key: str) -> int:
return int(os.environ[key])
# After: signature tells you exactly what can go wrong
@as_result(KeyError, ValueError)
def parse_port(key: str) -> int:
return int(os.environ[key])
# Test that it works
os.environ["TEST_PORT"] = "8080"
assert parse_port("TEST_PORT") == Ok(8080)
assert isinstance(parse_port("MISSING_KEY").err(), KeyError)
The function body stays the same. The only change is the decorator, and
the callers now get a Result instead of praying nothing blows up:
import os
from corrode import as_result, Ok, Err
@as_result(KeyError, ValueError)
def parse_port(key: str) -> int:
return int(os.environ[key])
def start_server(port: int) -> None:
pass # placeholder
os.environ["PORT"] = "3000"
match parse_port("PORT"):
case Ok(port):
start_server(port)
case Err(KeyError()):
start_server(8080)
case Err(ValueError() as e):
print(f"Invalid PORT: {e}")
Step 2: return Err(exception) explicitly
Once callers are adapted, you can drop the decorator and return errors explicitly. The function still uses exception classes, so the callers don't change:
import os
from corrode import Ok, Err, Result
def parse_port(key: str) -> Result[int, KeyError | ValueError]:
raw = os.environ.get(key)
if raw is None:
return Err(KeyError(key))
try:
return Ok(int(raw))
except ValueError as exc:
return Err(exc)
os.environ["PORT"] = "8080"
assert parse_port("PORT") == Ok(8080)
assert isinstance(parse_port("MISSING").err(), KeyError)
Step 3: replace exceptions with domain types
When you're ready, replace exception classes with dataclasses that carry exactly the data the caller needs:
import os
from dataclasses import dataclass
from corrode import Ok, Err, Result
@dataclass
class MissingKey:
key: str
@dataclass
class InvalidValue:
key: str
raw: str
type ConfigError = MissingKey | InvalidValue
def parse_port(key: str) -> Result[int, ConfigError]:
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))
os.environ["PORT"] = "8080"
assert parse_port("PORT") == Ok(8080)
assert parse_port("MISSING") == Err(MissingKey(key="MISSING"))
Each step is a small, safe refactoring. Your callers get progressively
better types, and mypy
