typing#

notes on type hints as actually used in projects like pdsx and fastmcp.

unions#

use | for unions, not Optional:

RecordValue = str | int | float | bool | None | dict[str, Any] | list[Any]

from pdsx/_internal/types.py

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

FilterParam = Annotated[
    str | None,
    Field(
        description=(
            "jmespath expression to filter/project the result. "
            "examples: '[*].{uri: uri, text: value.text}' (select fields), "
            "'[?value.text != null]' (filter items), "
            "'[*].uri' (extract values)"
        ),
    ),
]

the metadata travels with the type. MCP tools use this for parameter descriptions.

from pdsx/mcp/filterable.py

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 filterable(
    fn: Callable[P, R] | Callable[P, Awaitable[R]],
) -> Callable[P, Any] | Callable[P, Awaitable[Any]]:
    ...

ParamSpec captures the full signature (args and kwargs) for decorator typing.

from pdsx/mcp/filterable.py

ParamSpec#

for decorators that preserve function signatures:

@wraps(fn)
async def async_wrapper(
    *args: P.args, _filter: str | None = None, **kwargs: P.kwargs
) -> Any:
    result = await fn(*args, **kwargs)
    return apply_filter(result, _filter)

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 (plus any new parameters).

from pdsx/mcp/filterable.py

overload#

when return type depends on input type:

from typing import overload

@overload
def filterable(
    fn: Callable[P, Awaitable[R]],
) -> Callable[P, Awaitable[Any]]: ...

@overload
def filterable(
    fn: Callable[P, R],
) -> Callable[P, Any]: ...

def filterable(
    fn: Callable[P, R] | Callable[P, Awaitable[R]],
) -> Callable[P, Any] | Callable[P, Awaitable[Any]]:
    ...

type checkers know async functions get async wrappers, sync functions get sync wrappers.

from pdsx/mcp/filterable.py

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.

from fastmcp/tools/tool.py

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

sources: