Exploring algebraic effects inspired by Effect-TS, ZIO, and the Koka language
The Problem: Python’s Hidden Dependencies and Exceptions
Every developer encounters two questions when reading unfamiliar code:
- “What does this function need to work?”
- “What can go wrong when I call this?”
Consider a typical Python function:
def get_user_profile(user_id: str) -> UserProfile:
token = request.headers.get("Authorization")
user = auth_service.verify(token)
cached = cache.get(f"user:{user_id}")
if cached:
return cached
return database.find_user(user_id)
To answer our two questions, you must read the entire implementation. Where does auth_service come from? What happens if verify() fails? Can find_user() return None? The type signature (str) -> UserProfile tells you almost nothing.
This problem compounds in real applications with layered architectures:
handle_request()
→ get_user_profile()
→ authenticate()
→ check_cache()
→ query_db()
Each layer can have its own dependencies and failure modes. Traditional Python keeps dependencies implicit and errors unchecked. The result: runtime surprises, missing error handling, and unclear contracts.
What if the type signature told you everything?
def get_user_profile(user_id: str) -> Eff[
Dep[AuthService] | Dep[Database] | Dep[CacheService] | AuthError | NotFoundError,
UserProfile
]:
...
Now you know: this function needs AuthService, CacheService and Database, might fail with AuthError or NotFoundError, and returns UserProfile. All without reading a single line of implementation.
This is what the koka library enables.
What Are Algebraic Effects? (A Practical Take)
Skip the academic theory. Here’s the practical framing:
Effects are declared capabilities—what a function needs to run and how it can fail. Think of it as making the implicit explicit, and letting the type checker verify it.
The koka library provides two key effects:
-
Dep[T]— “I need an instance of type T to run” -
Err[E]— “I might fail with error E”
The key insight: effects compose automatically. If function A needs Dep[Database] and function B needs Dep[Cache], and function C calls both A and B, then C’s type automatically includes Dep[Database] | Dep[Cache]. The type checker infers this—no manual annotation needed.
The Inspiration Chain
This pattern didn’t emerge from nowhere. It follows a lineage of innovation:
- Academic research on algebraic effects and handlers (Plotkin, Pretnar, et al.)
- Koka language — a research language that pioneered practical algebraic effects
- ZIO (Scala) — brought effect systems to mainstream functional programming at zio.dev
- Effect-TS (TypeScript) — made effects accessible to the JavaScript ecosystem
- koka (Python) — brings these ideas to Python, leveraging Python 3.13’s type system
This library is an exploration of whether Python’s type system and generators can express the same patterns that have proven valuable in other ecosystems.
Real-World Example: User Lookup Service
Let’s build a realistic scenario with multiple failure modes that map to different HTTP responses.
Layer 1: Authentication
class AuthError(Exception):
"""Invalid or missing authentication. Maps to HTTP 401 Unauthorized."""
pass
class AuthService:
def verify(self, token: str) -> User | None:
...
def authenticate(token: str) -> Eff[Dep[AuthService] | AuthError, User]:
auth: AuthService = yield from Dep(AuthService)
user: User | None = auth.verify(token)
if user is None:
return (yield from Err(AuthError("Invalid token")))
return user
The type signature Eff[Dep[AuthService] | AuthError, User] tells you:
-
Needs:
AuthService -
Can fail with:
AuthError -
Returns:
User
Layer 2: Cache Lookup
class CacheMiss(Exception):
"""Internal signal—don't expose to HTTP clients."""
pass
class CacheService:
def get(self, key: str) -> UserProfile | None:
...
def get_from_cache(user_id: str) -> Eff[Dep[CacheService] | CacheMiss, UserProfile]:
cache: CacheService = yield from Dep(CacheService)
data: UserProfile | None = cache.get(f"user:{user_id}")
if data is None:
return (yield from Err(CacheMiss()))
return data
Layer 3: Database Lookup
class NotFoundError(Exception):
"""User doesn't exist. Maps to HTTP 404 Not Found."""
pass
class Database:
def find_user(self, user_id: str) -> UserProfile | None:
...
def get_from_db(user_id: str) -> Eff[Dep[Database] | NotFoundError, UserProfile]:
db: Database = yield from Dep(Database)
data: UserProfile | None = db.find_user(user_id)
if data is None:
return (yield from Err(NotFoundError(f"User {user_id} not found")))
return data
Layer 4: Composed Handler
Now we compose these layers into a single handler:
def get_user_profile(
token: str,
user_id: str
) -> Eff[
Dep[AuthService] | Dep[CacheService] | Dep[Database] | AuthError | NotFoundError,
UserProfile
]:
# First authenticate (can fail with AuthError → 401)
user: User = yield from authenticate(token)
# Try cache first (handle CacheMiss internally)
cache: CacheService = yield from Dep(CacheService)
cached: UserProfile | None = cache.get(f"user:{user_id}")
if cached is not None:
return cached
# Fall back to database (can fail with NotFoundError → 404)
return (yield from get_from_db(user_id))
The type signature reveals everything:
-
Dependencies:
AuthService,CacheService,Database -
Possible errors:
AuthError(→ 401),NotFoundError(→ 404) -
Return type:
UserProfile
Note that CacheMiss doesn’t appear in the final signature—we handled it internally by falling back to the database.
Running the Effect
result: UserProfile | AuthError | NotFoundError = (
Koka()
.provide(AuthService())
.provide(CacheService())
.provide(Database())
.run(get_user_profile("token-123", "user-456"))
)
# Pattern match to HTTP responses
match result:
case AuthError() as e:
return HttpResponse(401, f"Unauthorized: {e}")
case NotFoundError() as e:
return HttpResponse(404, f"Not Found: {e}")
case UserProfile() as profile:
return HttpResponse(200, profile.to_json())
The Koka runtime ensures all dependencies are provided. If you forget to .provide(Database()), you get a type error—not a runtime AttributeError.
How yield from Achieves This
The magic happens through Python’s generator protocol. Here’s how it works.
The Eff Type
type Eff[K, R] = Generator[K, Never, R]
-
K— the effects yielded (union ofDep[T]types and exception types) -
R— the return type
How Dep[T] Works
class Dep[T]:
def __init__(self, tpe: type[T]) -> None:
self.tpe = tpe
def __iter__(self) -> Generator[Self, T, T]:
return (yield self) # Yield the request, receive the instance
When you write db = yield from Dep(Database):
- The generator yields
Dep(Database)— a request for aDatabaseinstance - The runtime receives this request and looks up the handler
- The runtime sends the
Databaseinstance back into the generator -
dbreceives theDatabaseinstance
The type checker sees:
- You’re yielding
Dep[Database](adds to effect set K) - You’re receiving
Database(the return type)
How Err[E] Works
class Err[E: Exception]:
def __init__(self, err: E) -> None:
self.error = err
def __iter__(self) -> Generator[E, Never, Never]:
yield self.error # Yield the error, never resume
raise RuntimeError("Unreachable") # Type: Never
When you write return (yield from Err(NotFoundError(...))):
- The generator yields the
NotFoundErrorexception - The runtime receives it and returns it as the final result
- The generator never resumes (hence
Neverreturn type)
The type checker sees:
- You’re yielding
NotFoundError(adds to effect set K) - The expression type is
Never(computation doesn’t continue)
Effect Composition
When you yield from authenticate(token) inside get_user_profile:
-
authenticatehas effectsDep[AuthService] | AuthError - These get added to
get_user_profile‘s effect set - The type checker automatically computes the union:
Dep[AuthService] | AuthError | Dep[CacheService] | Dep[Database] | NotFoundError
This is why you don’t need to manually declare the effect types—the type checker infers them from usage.
The Alternative: Manual Return Types
Can we achieve the same type safety without generators? Let’s try using explicit return types.
Attempt: Pass Dependencies, Return Union Types
def authenticate(
auth: AuthService,
token: str
) -> User | AuthError:
user: User | None = auth.verify(token)
if user is None:
return AuthError("Invalid token")
return user
def get_from_db(
db: Database,
user_id: str
) -> UserProfile | NotFoundError:
data: UserProfile | None = db.find_user(user_id)
if data is None:
return NotFoundError(f"User {user_id} not found")
return data
def get_user_profile(
auth: AuthService,
cache: CacheService,
db: Database,
token: str,
user_id: str
) -> UserProfile | AuthError | NotFoundError:
# Check auth
auth_result: User | AuthError = authenticate(auth, token)
if isinstance(auth_result, AuthError):
return auth_result
user: User = auth_result
# Try cache
cached: UserProfile | None = cache.get(f"user:{user_id}")
if cached is not None:
return cached
# Try database
db_result: UserProfile | NotFoundError = get_from_db(db, user_id)
if isinstance(db_result, NotFoundError):
return db_result
return db_result
The Problems
1. Dependency Threading
Every function must explicitly accept and pass through all dependencies. get_user_profile takes 5 parameters—3 of which are just dependency plumbing. In deep call stacks, this becomes unmanageable:
def handle_request(
auth: AuthService,
cache: CacheService,
db: Database,
email: EmailService,
logger: Logger,
metrics: MetricsService,
token: str,
user_id: str
) -> ...:
# Every layer needs all these parameters
...
2. Manual Error Type Union
You must manually declare -> UserProfile | AuthError | NotFoundError. Forget one, and callers get type errors—or worse, the type checker misses a case. As you add more error types, the union grows unwieldy:
def complex_operation(...) -> Result | ErrorA | ErrorB | ErrorC | ErrorD | ErrorE:
...
3. Nested isinstance Checks Destroy Readability
result1 = step1(...)
if isinstance(result1, Error1):
return result1
result2 = step2(result1, ...)
if isinstance(result2, Error2):
return result2
result3 = step3(result2, ...)
if isinstance(result3, Error3):
return result3
# This continues for EVERY call that can fail
Linear code becomes a staircase of error checks, obscuring the happy path.
4. No Automatic Inference
The type checker can’t infer what dependencies or errors are needed—you must declare everything manually. Add a new error case deep in the call stack? Update every function signature up to the top.
Comparison Table
| Aspect |
yield from (koka) |
Return types |
|---|---|---|
| Dependencies | Inferred from usage | Explicit parameters |
| Error types | Inferred from Err() calls |
Manual union annotation |
| Control flow | Linear, readable | Nested isinstance checks |
| Composition | Automatic | Manual threading |
| Adding new dependency | Just use it | Update all callers |
| Adding new error type | Just yield it | Update all signatures |
The yield from Version (For Contrast)
def get_user_profile(
token: str,
user_id: str
) -> Eff[
Dep[AuthService] | Dep[CacheService] | Dep[Database] | AuthError | NotFoundError,
UserProfile
]:
user: User = yield from authenticate(token)
cache: CacheService = yield from Dep(CacheService)
cached: UserProfile | None = cache.get(f"user:{user_id}")
if cached is not None:
return cached
return (yield from get_from_db(user_id))
Two parameters. Linear flow. Types inferred automatically.
Why Not async/await?
A natural question: Python already has async/await for suspending and resuming functions. Why not use that?
Different problem domains. async/await solves I/O scheduling—letting the event loop run other tasks while waiting for network or disk. It doesn’t track “what capabilities a function needs.”
async def get_user(user_id: str) -> User:
... # What dependencies? What errors? The signature doesn't say.
There’s no mechanism to declare “this async function needs a Database” in a way the type checker can verify.
Function coloring. Async functions can only be called from async contexts. This “colors” your entire codebase—one async function forces async up the call stack. Generators don’t have this problem; they’re just values you can compose and run synchronously.
No custom interpretation. With async/await, the event loop is the only interpreter. With generators, we can write custom runtimes that handle effects differently—inject mocks for testing, swap implementations for different environments, etc.
An Experiment Worth Exploring
Let’s be clear: koka is experimental. It’s a proof of concept exploring whether Python can express the effect patterns that have proven valuable in TypeScript (Effect-TS), Scala (ZIO), and research languages (Koka).
What makes this possible now?
Python 3.13 introduced new type syntax (PEP 695) that makes generic types more ergonomic. Combined with mature type checkers like pyright, we can finally express complex type relationships that would have been impossibly verbose before.
What’s the value for Python developers?
This library demonstrates that type-checked effect systems are possible in Python—today, with zero runtime dependencies beyond standard generators. It shows a path toward code where:
- Dependencies are explicit and verified at type-check time
- Errors are tracked and exhaustively handled
- Function signatures serve as reliable documentation
This invites a question for the Python community: Should we have better support for this pattern? Could future Python versions make effect tracking even more ergonomic?
Try it yourself:
pip install koka
The goal: answer “what does this function need?” and “what can go wrong?” at a glance. Let your type checker become your documentation.
