typing #
notes on type hints as actually used in projects like pdsx (an atproto record CLI + MCP server) and fastmcp (an mcp server framework).
unions #
use | for unions, not Optional:
RecordValue = str | int | float | bool | None | dict[str, Any] | list[Any]
from pdsx/_internal/types.py (pdsx is an atproto record CLI + MCP server)
TypedDict #
for structured dictionaries where you know the shape:
from typing import TypedDict
class RecordResponse(TypedDict):
"""a record returned from list or get operations."""
uri: str
cid: str | None
value: dict
class CredentialsContext(TypedDict):
"""credentials extracted from context or headers."""
handle: str | None
password: str | None
pds_url: str | None
repo: str | None
better than dict[str, Any] because the structure is documented and checked.
from pdsx/mcp/_types.py
Annotated #
attach metadata to types. useful for documentation and schema generation:
from typing import Annotated
from pydantic import Field
Limit = Annotated[
int,
Field(description="maximum number of records to return", gt=0, le=100),
]
the metadata travels with the type. MCP tools use this for parameter descriptions — fastmcp folds the Field description into the generated schema.
Protocol #
define what methods something needs, not what class it is:
from typing import Protocol
class ContextSamplingFallbackProtocol(Protocol):
async def __call__(
self,
messages: str | list[str | SamplingMessage],
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> ContentBlock: ...
any callable matching this signature satisfies the protocol. no inheritance required.
from fastmcp/utilities/types.py
generics #
TypeVar for generic functions and classes:
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def traced(
fn: Callable[P, R] | Callable[P, Awaitable[R]],
) -> Callable[P, R] | Callable[P, Awaitable[R]]:
...
ParamSpec captures the full signature (args and kwargs) for decorator typing.
ParamSpec #
for decorators that preserve function signatures:
@wraps(fn)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
log.debug("calling %s", fn.__name__)
return await fn(*args, **kwargs)
P.args and P.kwargs carry the original function's parameter types into the wrapper. type checkers see the wrapper with the same signature as the wrapped function.
overload #
when return type depends on input type:
from typing import overload
@overload
def traced(
fn: Callable[P, Awaitable[R]],
) -> Callable[P, Awaitable[R]]: ...
@overload
def traced(
fn: Callable[P, R],
) -> Callable[P, R]: ...
def traced(
fn: Callable[P, R] | Callable[P, Awaitable[R]],
) -> Callable[P, R] | Callable[P, Awaitable[R]]:
...
type checkers know async functions get async wrappers, sync functions get sync wrappers.
TYPE_CHECKING #
avoid runtime import costs for types only needed for hints:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
the import doesn't happen at runtime, only when type checkers analyze the code. with from __future__ import annotations, you don't need string quotes.
import organization #
"""module docstring."""
from __future__ import annotations
import stdlib_module
from typing import TYPE_CHECKING
from third_party import thing
from local_package import helper
if TYPE_CHECKING:
from expensive import Type