Skip to content

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 properly
def 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 typing
from 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]()
# Usage
container = Container()
container.register(list[int], MyListImplementation)
result = container.resolve(list[int]) # Returns MyListImplementation()

Example 2: ORM Type Mapping

from typing_extensions import TypeForm
from dataclasses import dataclass
@dataclass
class 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...
# Usage
users = query_by_type(User) # Properly typed as list[User]

Example 3: Serialization/Deserialization

from typing_extensions import TypeForm, Union
import 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
# Usage
result = from_json('[1, 2, 3]', list[int]) # Returns list[int]

Migration Guide

Before PEP 747

# Workarounds and hacks
def try_cast1(typx: type, value: object): # Limited to concrete types
# Can't handle list[int] or str | None
pass

After PEP 747

# Proper type annotations
def try_cast[T](typx: TypeForm[T], value: object) -> T | None:
# Can handle any type expression
pass

Use Cases Matrix

Use CaseBefore PEP 747After 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)}")
# Usage
validated_list = validate_type([1, 2, 3], list[int]) # Works perfectly

Dynamic 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 schema

Best Practices

  1. Always import from typing_extensions: from typing_extensions import TypeForm
  2. Use generic type parameters: def func[T](typx: TypeForm[T]) -> T
  3. Combine runtime type checking: if isinstance(value, get_origin(typx))
  4. Handle union types properly: Check for Optional[T] patterns
  5. Document TypeForm usage: Help maintainers understand the PEP 747 advantage

Common Pitfalls

# DON'T: Use concrete types only
def process_list(items: list) -> list: # Loses type information
# DO: Use TypeForm for generic container types
def process_list[T](items: TypeForm[T]) -> list[T]: # Preserves type info

Performance Considerations

  • TypeForm has 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments