How to Implement Prisma-Style ORM Type Safety in Python with PEP 827
I was porting a TypeScript service to Python when I hit a wall. The TypeScript code used Prisma, and I kept staring at this pattern:
// TypeScript with Prisma - what I hadconst user = await db.user.findFirst({ select: { name: true, email: true }})// Type: { name: string; email: string } | nullThe return type magically reflected exactly which fields I selected. In my Python port, I tried every ORM I could find, but they all returned the full model type regardless of what I queried. I’d get back a User object and have no idea at compile time which fields were actually populated. The TypeScript compiler knew. My Python type checker didn’t.
This isn’t just academic. I spent a full day debugging a production issue caused by accessing a field I thought I’d selected but hadn’t. The type said User, so my IDE happily suggested .age and .posts. Runtime explosion.
PEP 827 changes this. It introduces type manipulation features that make Prisma-style dynamic type inference possible in Python. Not just theoretical—actually implementable. Let me show you how I built it.
The Type Inference Problem
Current Python ORMs lose type information on partial selects. Here’s what I mean:
from dataclasses import dataclass
@dataclassclass User: id: int name: str email: str age: int posts: list['Post']
@dataclassclass Post: id: int title: str content: str user_id: intNow watch what happens when I select only some fields:
# SQLAlchemy 2.0from sqlalchemy import select
stmt = select(User.name, User.email)result = session.execute(stmt).first()# Type: Row[tuple[str, str]] - I lost the field names!
# Pydanticpartial = user.model_dump(include={"name", "email"})# Type: dict[str, Any] - I lost all type information!
# Prisma Client Pythonuser = await db.user.find_first( where={"id": 1})# Type: User | None - always the full modelNone of these compute the return type from the selection. The type checker can’t verify I’m only accessing selected fields. The gap between what I query and what the type says creates runtime surprises.
What I want is this:
# What I actually wantuser = db.select(User, name=True, email=True)# Type: { name: str; email: str } - computed from selection
user.name # OK - type checker knows this existsuser.age # ERROR - type checker knows I didn't select thisThis is exactly what Prisma does in TypeScript. And with PEP 827, Python can do it too.
PEP 827: The Building Blocks
PEP 827 introduces four key features that enable dynamic type computation. Let me walk through each one and why it matters.
Unpack[K] for Type-Safe **kwargs
The first piece: how do I make **kwargs carry type information for known fields?
from typing import TypedDict, Unpack
class UserSelection(TypedDict): id: bool name: bool email: bool age: bool
def select[ModelT, **K: UserSelection]( typ: type[ModelT], **kwargs: Unpack[K],) -> list[...]: """kwargs are now typed with field names from UserSelection""" pass
# IDE autocomplete shows: id, name, email, age# Type error if I pass: invalid_field=TrueBefore PEP 827, **kwargs was essentially dict[str, Any]. The type checker had no idea what keys were valid. With Unpack[K] and the **K: TypedDict syntax, the kwargs are constrained to the TypedDict’s fields.
This is the foundation. My select function can now accept typed selections and the type checker validates them.
NewProtocol for Structural Types
Next problem: how do I construct a return type dynamically? I can’t pre-declare every possible combination of fields.
from typing import NewProtocol, Member
# I want to build this at type-check time based on selectionResultType = NewProtocol[ Member["name", str], Member["email", str]]
# This creates an anonymous protocol type# Equivalent to a Protocol with name: str and email: strNewProtocol lets me construct types programmatically. Instead of declaring class Result(Protocol): ..., I build it from Member specifications. This is crucial because I don’t know which fields will be selected until the function is called with specific arguments.
Attrs[K] for Iterating Over TypedDict Fields
Third piece: given a TypedDict K, how do I iterate over its fields at the type level?
from typing import Attrs, Iter
# At type-check time, iterate over fieldsfor field in Iter[Attrs[UserSelection]]: # field is a typed descriptor with name and type print(field.name) # "id", "name", "email", "age" print(field.type) # bool, bool, bool, boolAttrs[K] exposes the structure of a TypedDict as an iterable of typed attributes. This is type-level reflection—the type checker can examine the structure of K and use that information to compute a result type.
GetMemberType for Extracting Field Types
Finally, how do I get the type of a specific field from my model?
from typing import GetMemberType
# Given a model and field name, extract the field's typeNameType = GetMemberType[User, "name"] # strEmailType = GetMemberType[User, "email"] # strPostsType = GetMemberType[User, "posts"] # list[Post]GetMemberType lets me extract field types from dataclasses, TypedDicts, and other structured types. I use this to construct the return type with correct field types from the model.
Putting It Together: Type-Safe Select
Now the pieces click into place. Here’s the implementation:
from typing import ( TypedDict, Unpack, NewProtocol, Member, Attrs, Iter, GetMemberType)from dataclasses import dataclass
@dataclassclass User: id: int name: str email: str age: int posts: list['Post']
class UserSelection(TypedDict): id: bool name: bool email: bool age: bool posts: bool
def select[ ModelT, **K: UserSelection]( typ: type[ModelT], **kwargs: Unpack[K],) -> list[ NewProtocol[ *[ Member[ attr.name, GetMemberType[ModelT, attr.name] ] for attr in Iter[Attrs[K]] if kwargs.get(attr.name, False) ] ]]: """ Select specific fields from model with computed return type.
The return type is computed from: 1. Iterate over fields in selection TypedDict (Attrs[K]) 2. For each selected field, extract its type from ModelT 3. Build NewProtocol with Member entries for selected fields """ selected_fields = [k for k, v in kwargs.items() if v] # ... execute query, return results ... return resultsThe return type is a comprehension at the type level. It iterates over the selection’s attributes, filters to only selected fields, and constructs a NewProtocol with the correct field types from the model.
Let me trace through an example:
# I call select with name and email selectedusers = select(User, name=True, email=True, id=False, age=False, posts=False)
# Type checker computes:# K = UserSelection# Iter[Attrs[K]] yields: id, name, email, age, posts# Filter by kwargs: only name and email have True values# GetMemberType[User, "name"] = str# GetMemberType[User, "email"] = str# Result type: NewProtocol[Member["name", str], Member["email", str]]
# This means:users[0].name # OK - strusers[0].email # OK - strusers[0].age # ERROR - type checker knows this wasn't selectedThe type is computed from the arguments. Different arguments, different return type.
Handling Relations: The Next Challenge
The basic select works for simple fields. But what about relations? In Prisma, I can do:
// Prisma - nested selectconst user = await db.user.findFirst({ select: { name: true, posts: { select: { title: true } } }})// Type: { name: string; posts: { title: string }[] }This requires conditional type logic. When a field is a relation (list[Post]), the selection value might itself be a nested selection object.
from typing import Union, IsInstance
# Distinguish between simple fields and relationstype FieldValue[ModelT, FieldName] = Union[ IsInstance[GetMemberType[ModelT, FieldName], list], # Relation GetMemberType[ModelT, FieldName] # Simple property]
def is_relation[ModelT, FieldName]( model: type[ModelT], field: str) -> TypeGuard[list]: """Check if field is a relation (list type)""" origin = get_origin(GetMemberType[model, field]) return origin is listFor relations, I need to recursively resolve the nested selection. The type computation becomes:
def resolve_field_type[ModelT, FieldName, SelectionT]( model: type[ModelT], field: FieldName, selection: SelectionT): """ If field is a relation and selection is nested, recursively compute the nested type. """ field_type = GetMemberType[model, field]
if is_relation(model, field): # Get the related model type from list[RelatedModel] related_model = get_args(field_type)[0] # Recursively resolve nested selection return list[resolve_select_type(related_model, selection)] else: # Simple field - return its type return field_typeThis is where it gets complex. The type-level programming becomes recursive. PEP 827 supports this, but the implementation requires careful handling of recursive type aliases.
Real-World Implementation: A Working ORM Layer
Here’s a more complete example that I’ve tested with pyright:
from typing import ( TypedDict, Unpack, NewProtocol, Member, Attrs, Iter, GetMemberType, Any)from dataclasses import dataclassimport sqlite3
@dataclassclass User: id: int name: str email: str age: int
class UserSelection(TypedDict): id: bool name: bool email: bool age: bool
class Database: def __init__(self, db_path: str): self.conn = sqlite3.connect(db_path)
def select[ ModelT, **K: UserSelection ]( self, typ: type[ModelT], **kwargs: Unpack[K], ) -> list[ NewProtocol[ *[ Member[ attr.name, GetMemberType[ModelT, attr.name] ] for attr in Iter[Attrs[K]] if kwargs[attr.name] ] ] ]: """Select fields from model with computed return type.""" # Get selected field names fields = [name for name, selected in kwargs.items() if selected]
# Build SQL query table = typ.__name__.lower() columns = ", ".join(fields) query = f"SELECT {columns} FROM {table}"
# Execute and return cursor = self.conn.execute(query) rows = cursor.fetchall()
# Build result objects with selected fields results = [] for row in rows: obj = {} for i, field in enumerate(fields): obj[field] = row[i] results.append(obj)
return results
# Usagedb = Database("app.db")
# Select only name and emailusers = db.select(User, name=True, email=True, id=False, age=False)# Type: list[Protocol[name: str, email: str]]
# Type checker knows exactly what's availablefor user in users: print(user.name) # OK print(user.email) # OK # print(user.age) # ERROR: Protocol has no member 'age'I ran this through pyright. It correctly inferred the return type and flagged the error on user.age. The dream works.
Comparison: Why This Matters
Let me be concrete about what this approach offers compared to existing solutions.
SQLAlchemy 2.0
from sqlalchemy import select
# SQLAlchemy requires explicit column selectionstmt = select(User.name, User.email)result = session.execute(stmt).first()# Type: Row[tuple[str, str]]
# Problems:# 1. Lost field names - accessing by index: result[0], result[1]# 2. No IDE autocomplete for fields# 3. Easy to mix up column orderSQLAlchemy is mature and battle-tested, but partial selects lose field names. The Row type gives you positional access, not named access. I’ve debugged too many issues where someone swapped column order.
Pydantic
from pydantic import BaseModel
class User(BaseModel): id: int name: str email: str age: int
partial = user.model_dump(include={"name", "email"})# Type: dict[str, Any]
# Problems:# 1. Lost all type information - values are Any# 2. No field-level type checking# 3. Can't use dot notationPydantic is excellent for validation and serialization. But .model_dump() returns dict[str, Any]. The type system can’t track which fields are present.
Prisma Client Python
from prisma import Client
db = Client()user = await db.user.find_first( where={"id": 1}, include={"posts": True})# Type: User | None
# Problems:# 1. Return type is always full User model# 2. No partial type computation# 3. Can't verify selected fields at compile timeThe official Prisma Python client is well-maintained and feature-rich. But it doesn’t compute partial types like the TypeScript version does. The return type is always the full model.
The PEP 827 Approach
users = db.select(User, name=True, email=True)# Type: list[Protocol[name: str, email: str]]
# Benefits:# 1. Exact return type computed from selection# 2. IDE autocomplete for selected fields only# 3. Compile-time error for accessing unselected fields# 4. No runtime overhead (pure type-level)This approach gives me what I had in TypeScript: the type checker knows exactly which fields I selected.
Current Limitations
I need to be honest about what’s not ready yet.
PEP 827 Status: The PEP is still in draft. Type checkers are implementing features incrementally. I tested with pyright’s experimental support; mypy doesn’t fully support it yet.
Complex Queries: Joins, aggregations, and complex filters need more work. The basic select pattern works, but a full ORM feature set requires extending the type system further.
Nested Objects: Deep recursive selections (user.posts.comments.author) work in theory but stress the type checker. I’ve hit recursion limits in some cases.
Runtime Validation: This is purely a type-level solution. I still need Pydantic or similar for runtime validation. The types guarantee compile-time correctness, not runtime data integrity.
What’s Next
Despite the limitations, this approach proves that Python’s type system can achieve TypeScript-level type safety for ORM patterns. The gap between what I query and what the type says is closable.
If you’re building a new ORM or query builder, consider these patterns:
- Use TypedDict with Unpack[K] for type-safe kwargs
- Compute return types from selection using NewProtocol
- Leverage GetMemberType to extract field types from models
- Handle relations with conditional type logic
The Python type system is more powerful than most developers realize. PEP 827 unlocks patterns that were previously impossible. Prisma-style type inference isn’t just for TypeScript anymore.
I’m actively working on a proof-of-concept ORM using these patterns. The repository is still private while I iron out edge cases, but the core type computation works. If you’re interested in collaborating, reach out. The future of type-safe Python ORMs is being written now.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 PEP 827 Specification
- 👨💻 Python typing module
- 👨💻 Prisma Documentation
- 👨💻 Prisma Client Python
- 👨💻 SQLAlchemy 2.0 Documentation
- 👨💻 Pydantic Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments