How to Annotate Functions That Accept Types as Values in Python: PEP 747 Guide
Purpose
This post demonstrates how to annotate functions that accept types as values in Python using PEP 747’s TypeForm. The key point is that TypeForm enables proper typing for functions that need to work with type expressions like list[int] or str | None at runtime.
The Problem: Untyped Land
When I saw this Reddit comment, it clicked:
“I have huge codebase that is stuck in untyped land because it uses types as values
This is exactly the pain PEP 747 solves. Here’s what the problem looks like:
# Before PEP 747 - Impossible to annotate properlydef deserialize_value(typx: ???, data: Any) -> Any: # What goes here? if typx is int: return int(data) elif typx is list: return [deserialize_value(typx.__args__[0], item) for item in data] # etc...The annotation ??? is the problem - there’s no way to properly type a function that accepts type expressions as arguments.
The Solution: TypeForm
PEP 747 introduces TypeForm to solve this. Here’s how the same function looks after:
# After PEP 747 - Clean, proper typingfrom typing_extensions import TypeForm
def deserialize_value[T](typx: TypeForm[T], data: Any) -> T: if typx is int: return int(data) elif typx is list: return [deserialize_value(typx.__args__[0], data)] # etc...The key change is TypeForm[T] - this properly annotates that the function accepts a type expression and returns a value of that type.
How It Works
TypeForm works with any type expression. Here are practical examples:
Example 1: Dependency Injection Framework
from typing_extensions import TypeForm
class Container: def __init__(self): self.services = {}
def register[T](self, interface: TypeForm[T], implementation: type): self.services[interface] = implementation
def resolve[T](self, interface: TypeForm[T]) -> T: return self.services[interface]()
# Usagecontainer = Container()container.register(list[int], MyListImplementation)result = container.resolve(list[int]) # Returns MyListImplementation()Example 2: ORM Type Mapping
from typing_extensions import TypeFormfrom dataclasses import dataclass
@dataclassclass User: id: int name: str tags: list[str]
def query_by_type[T](model: TypeForm[T]) -> list[T]: # Simulate database query if model is User: # Generate SQL based on model fields return database.execute("SELECT * FROM users") # Handle other models...
# Usageusers = query_by_type(User) # Properly typed as list[User]Example 3: Serialization/Deserialization
from typing_extensions import TypeForm, Unionimport json
def from_json[T](json_str: str, target_type: TypeForm[T]) -> T: data = json.loads(json_str) return cast_to_type(data, target_type)
def cast_to_type[T](value: Any, typx: TypeForm[T]) -> T: origin = get_origin(typx) args = get_args(typx)
if origin is Union: # Handle Optional[T] = Union[T, None] if None in args: return value if value is not None else None elif origin is list: return [cast_to_type(item, args[0]) for item in value] elif origin is dict: return {k: cast_to_type(v, args[1]) for k, v in value.items()}
return value
# Usageresult = from_json('[1, 2, 3]', list[int]) # Returns list[int]Migration Guide
Before PEP 747
# Workarounds and hacksdef try_cast1(typx: type, value: object): # Limited to concrete types # Can't handle list[int] or str | None passAfter PEP 747
# Proper type annotationsdef try_cast[T](typx: TypeForm[T], value: object) -> T | None: # Can handle any type expression passUse Cases Matrix
| Use Case | Before PEP 747 | After PEP 747 |
|---|---|---|
| DI Framework | ❌ Untyped | ✅ Fully typed |
| ORM Field Mapping | ❌ Limited | ✅ Complete |
| Serialization | ❌ Manual typing | ✅ Generic |
| Validation Framework | ❌ Basic | ✅ Advanced |
| Generic Factories | ❌ Incomplete | ✅ Robust |
Advanced Patterns
Generic Validators
from typing_extensions import TypeForm
def validate_type[T](value: object, expected: TypeForm[T]) -> T: if isinstance(value, get_origin(expected) or expected): return value raise TypeError(f"Expected {expected}, got {type(value)}")
# Usagevalidated_list = validate_type([1, 2, 3], list[int]) # Works perfectlyDynamic Schema Building
def build_schema[T](model: TypeForm[T]) -> dict: """Build JSON schema from type annotation"" schema = {"type": "object"} if hasattr(model, '__annotations__'): schema["properties"] = { name: build_type_hint(type_hint) for name, type_hint in model.__annotations__.items() } return schemaBest Practices
- Always import from typing_extensions:
from typing_extensions import TypeForm - Use generic type parameters:
def func[T](typx: TypeForm[T]) -> T - Combine runtime type checking:
if isinstance(value, get_origin(typx)) - Handle union types properly: Check for
Optional[T]patterns - Document TypeForm usage: Help maintainers understand the PEP 747 advantage
Common Pitfalls
# DON'T: Use concrete types onlydef process_list(items: list) -> list: # Loses type information
# DO: Use TypeForm for generic container typesdef process_list[T](items: TypeForm[T]) -> list[T]: # Preserves type infoPerformance Considerations
TypeFormhas minimal runtime overhead- Type information is available at runtime for dynamic processing
- Enables runtime type checking without sacrificing static analysis
Summary
In this post, I showed how to use TypeForm from PEP 747 to properly annotate functions that accept type expressions. The key point is that TypeForm enables proper typing for dependency injection, ORMs, and serialization frameworks that were previously stuck in untyped land.
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 747 Official
- 👨💻 Reddit Discussion
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments