Python Type System
When to Use
Use this skill when:
- The user asks about TypeVar, Generic, Protocol, ParamSpec, TypeVarTuple, or Concatenate and needs to understand how to apply them correctly in their codebase
- The user is designing a reusable library component (container class, decorator, utility function) and needs type-safe generic abstractions that work correctly in mypy, pyright, and pyright-based IDEs like VS Code
- The user is creating a structural interface for a plugin system, strategy pattern, or callback-based API where nominal inheritance is not desirable or practical
- The user needs to type a decorator that wraps arbitrary functions while preserving full parameter and return type signatures for callers
- The user asks about type narrowing -- TypeGuard, isinstance branches, assert_never, Literal types in match/case -- and needs to eliminate type errors without resorting to cast()
- The user has multiple overloaded function signatures (e.g., a function that returns str when given str, bytes when given bytes) and needs @overload to express this precisely
- The user is preparing a Python package for distribution on PyPI and needs to understand py.typed markers, inline types vs stub files, and PEP 561 compliance
- The user is migrating a large untyped codebase and needs a strategy for incremental adoption without breaking existing code
- The user is hitting mypy or pyright errors related to variance (invariant containers, covariant return types, contravariant function parameters) and cannot understand why
Do NOT use this skill when:
- The user wants basic variable, parameter, or return type annotations like
def greet(name: str) -> str -- use python-idioms instead
- The user wants to validate untrusted external data (API responses, config files, user input) at runtime -- use
python-data-modeling for Pydantic or dataclasses-based validation
- The user is setting up a Python project, configuring pyproject.toml, or choosing between mypy and pyright for a new project -- use
python-project-setup
- The user wants to write tests, including tests that verify type behavior -- use
python-testing-patterns
- The user is asking about error handling patterns, custom exception hierarchies, or exception chaining -- use
python-error-handling
- The user asks about data classes specifically for modeling domain entities -- use
python-data-modeling
- The user is asking about performance optimization of Python code rather than type safety
Process
1. Diagnose the Typing Need Precisely
Before writing any type annotation code, identify which problem the user is actually solving. The wrong tool produces annotations that are technically valid but mislead callers or cause downstream type errors.
- Generic container or function with one flexible element type: Use
TypeVar. If the type must support comparison, sorting, or arithmetic, add a bound. If the function can only accept int or float specifically, use constraints (not a bound).
- Structural interface where you cannot require inheritance: Use
Protocol. This is the right choice for third-party extensibility, callback types with multiple parameters, and any time you want duck typing with static verification.
- Decorator that wraps arbitrary callables: Use
ParamSpec. Without it, the wrapper loses all knowledge of the wrapped function's parameters, breaking autocomplete and mypy validation for callers.
- Function with overloaded signatures that differ in input/output type relationship: Use
@overload. The classic cases are: input str → output str, input bytes → output bytes; or optional parameter presence controlling return type; or Literal values selecting return types.
- Narrowing from a broad type (like
object or a union) inside a guard function: Use TypeGuard[T]. If the narrowing is only valid for the True branch, use TypeGuard. If it is bidirectional (False branch narrows the other way), Python 3.13 added TypeIs[T].
- Variadic generic (functions that preserve the types of arbitrary-length argument tuples): Use
TypeVarTuple with Unpack. The canonical use case is a typed zip or a function that transforms a heterogeneous tuple while preserving element types.
- Exhaustive match/case with sum types: Use
Never with assert_never() from typing_extensions or Python 3.11+.
2. Set Up the Typing Environment Correctly
Type annotations interact with Python version, import style, and checker configuration. Get this right before writing type code.
- Python version targeting matters:
TypeVar, Generic, Protocol are in typing since Python 3.5. ParamSpec and Concatenate require Python 3.10+ from typing or typing_extensions for 3.8+. TypeVarTuple and Unpack require Python 3.11+ or typing_extensions >= 4.0. TypeIs requires Python 3.13+ or typing_extensions >= 4.10. Always import from typing_extensions for backwards compatibility in libraries.
- Use
from __future__ import annotations (PEP 563) when: your file contains forward references (a class referencing itself, or two classes referencing each other), or you want to avoid importing expensive runtime objects just for annotations. Be aware: this makes ALL annotations strings at runtime, which breaks get_type_hints() behavior in some frameworks (FastAPI, Pydantic v1). Test your framework compatibility before enabling it globally.
- Avoid mixing old-style typing imports with built-in generics. In Python 3.9+, use
list[int], dict[str, int], tuple[int, ...], and type[MyClass] directly. Do not mix typing.List[int] with list[int] in the same codebase. Pick one and enforce it with a linter rule (UP006, UP007 in ruff).
- Configure your type checker strictly for new code: For mypy, use
strict = true in mypy.ini or pyproject.toml. For pyright, use typeCheckingMode = "strict". Understand what these flags enable: --disallow-untyped-defs, --disallow-any-generics, --warn-return-any, --check-untyped-defs. Apply strict mode file-by-file using # mypy: strict if full-project strict is not yet feasible.
3. Design TypeVar and Generic Class Hierarchies
TypeVar is the foundation of generic Python code. Most typing bugs come from misusing it.
- Define TypeVars at module scope, never inside functions. A TypeVar defined inside a function is a new object on every call, breaking the identity check that type checkers use to correlate variables.
- Name TypeVars by convention: single uppercase
T, K, V for simple cases; descriptive names with _T suffix for domain-specific vars: ReturnType_T, ElementType_T. Covariant vars suffix _co, contravariant suffix _contra.
- Bound vs. constraints:
T = TypeVar("T", bound=Comparable) means "T must be a subtype of Comparable or Comparable itself" -- the generic can receive any conforming type and still return that exact type. T = TypeVar("T", int, float) means "T must be exactly int or exactly float" -- the checker picks one per call site. Use bound for protocol-constrained generics; use constraints only when you genuinely need to enumerate the exact allowed types.
- Variance rules: A
list[Dog] is NOT a list[Animal] because lists are mutable (invariant). A Callable[[Animal], None] IS a Callable[[Dog], None] (contravariant in parameters). A Supplier[Dog] IS a Supplier[Animal] if Supplier only produces values (covariant in output). Get variance wrong and mypy will correctly reject assignments that seem intuitive.
- Covariant containers: Use
T_co = TypeVar("T_co", covariant=True) for read-only containers like Sequence, Iterable, Iterator, Mapping (keys are invariant, values are covariant). Define class ImmutableStack(Generic[T_co]) to allow ImmutableStack[Dog] to satisfy ImmutableStack[Animal].
- Self type for fluent APIs and class methods: In Python 3.11+, use
from typing import Self. In earlier versions, use T = TypeVar("T", bound="MyClass") and annotate def clone(self: T) -> T. This ensures subclass methods return the subclass type, not the base class.
- In Python 3.12+: use the PEP 695 syntax
def first[T](items: list[T]) -> T and class Stack[T]: instead of the explicit TypeVar declaration. This is cleaner and avoids the module-scope naming requirement.
4. Design Protocol Interfaces for Structural Subtyping
Protocol is the correct tool for any interface that external or third-party code will implement.
- Define the minimum viable protocol. Every method and attribute on a Protocol is a requirement for conformance. Add only what the consumer of the protocol actually uses. A protocol with 12 methods that only uses 2 of them in the consuming function is a design smell.
- Use Protocol instead of ABC for externally-consumed interfaces. ABCs require explicit inheritance (
class MyPlugin(PluginBase)), creating a tight coupling. Protocols require only structural compatibility -- any class with matching methods qualifies. This is more Pythonic and more compatible with code you do not control.
- Attribute protocols: Include attributes in protocols with explicit type annotations. The conforming class can implement them as instance variables, properties, or class variables -- the protocol does not care which, only that the attribute is accessible.
runtime_checkable Protocol is NOT a full runtime type check. It only checks for method and attribute names via isinstance(), not for their signatures or return types. isinstance(obj, Plugin) will return True for any object with a name attribute and initialize, execute, shutdown methods -- even if their signatures are completely wrong. Use it only for duck-typing dispatch, not for validation of untrusted input.
- Generic Protocols: Combine Protocol with Generic for parameterized structural interfaces.
class Repository(Protocol[T]): defines a protocol that is generic in its entity type. Conforming classes must be generic too, or must specialize the TypeVar concretely.
- Callback protocols: Use Protocol to type callable objects with specific signatures that cannot be expressed with
Callable. class Transformer(Protocol[T, R]): with def __call__(self, value: T, *, timeout: float) -> R: ... expresses a keyword-argument-carrying callable precisely.
- Protocol inheritance: Protocols can inherit from other Protocols, creating interface hierarchies without runtime coupling.
class ReadableStream(Protocol): and class ReadWriteStream(ReadableStream, Protocol): composes the interfaces structurally.
5. Type Decorators with ParamSpec and Concatenate
Decorators are the most commonly mistyped Python construct. Most decorator typing bugs result in the wrapper being typed as (*args: Any, **kwargs: Any) -> Any, losing all IDE support for the wrapped function.
- The correct ParamSpec pattern:
P = ParamSpec("P") and R = TypeVar("R"). The decorator receives Callable[P, R] and returns Callable[P, R]. The wrapper function uses *args: P.args, **kwargs: P.kwargs exactly -- these are special forms that only work as P.args and P.kwargs together.
- Do NOT add regular parameters between
P.args in the wrapper signature. def wrapper(extra: str, *args: P.args, **kwargs: P.kwargs) is invalid. If you need to add parameters to the wrapped function's interface, use Concatenate[ExtraParam, P] in the outer decorator type.
- Concatenate pattern:
def with_auth(func: Callable[Concatenate[AuthToken, P], R]) -> Callable[P, R] expresses a decorator that consumes the first AuthToken argument and exposes the remaining P parameters to callers. This correctly types decorators that inject dependencies as the first argument.
- Class-based decorators with ParamSpec: When implementing a decorator as a class with
__call__, annotate def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R. The class itself is generic in P and R.
- Stacked decorators: When multiple decorators are applied, each one's
Callable[P, R] input and output must chain correctly. If a middle decorator changes the signature, its output type must match the next decorator's input type precisely.
6. Apply Type Narrowing Correctly
Type narrowing is how the type checker tracks type information through conditional branches. Misusing narrowing tools produces silently incorrect type information.
isinstance narrowing is the safest and most reliable form. if isinstance(x, str): causes mypy and pyright to narrow x to str inside the branch. For union types str | int, both branches are narrowed correctly. For object, narrowing to a concrete type inside the branch is reliable.
TypeGuard[T] for custom guard functions: The function must return bool. When it returns True, the type checker narrows the first parameter to T. The narrowing is ONLY applied in the True branch -- the False branch still has the original type. This is intentional and asymmetric.
TypeIs[T] (Python 3.13+ or typing_extensions): Unlike TypeGuard, TypeIs narrows in both branches. If is_str(x) returns True, x is str. If it returns False, x is narrowed to "the original type minus str". Prefer TypeIs over TypeGuard when you have symmetric narrowing.
cast() is a promise to the type checker that you cannot enforce. It tells the checker "trust me, this is type T" with zero runtime enforcement. Use it only as an absolute last resort when interacting with dynamically typed code you cannot annotate (e.g., plugin loading via importlib, metaclass magic). Document why the cast is safe every time you use it.
assert_never(x) for exhaustive match: Import from typing (3.11+) or typing_extensions. Place it in the default branch of a match statement or if/elif chain over a union. If any case is unhandled, mypy will report an error because x will not be narrowed to Never. This is the correct pattern for sum types:
def handle(event: LoginEvent | LogoutEvent | ErrorEvent) -> str:
match event:
case LoginEvent():
return "logged in"
case LogoutEvent():
return "logged out"
case ErrorEvent():
return "error"
case _ as unreachable:
assert_never(unreachable) # mypy error if a new event type is added to the union
- Literal types for value-based narrowing:
def process(mode: Literal["read", "write"]) -> None enables callers to pass only exact string values. Combined with overload, Literal types enable return-type selection based on exact values.
reveal_type(x) is a development-only tool (no import needed in mypy; import from typing in Python 3.11+ to silence the NameError at runtime). Use it to debug what the type checker currently believes about a variable. Remove before committing.
7. Distribute a Typed Library Correctly (PEP 561)
A Python library distributed on PyPI must follow PEP 561 to be recognized as typed by mypy, pyright, and other consumers.
- Include a
py.typed marker file. It is an empty file placed at the root of the package (alongside __init__.py). Its presence signals to type checkers that the package has inline type annotations. Without it, mypy defaults to treating the package as untyped (unless the user enables ignore_missing_imports = false).
- Declare the marker in
pyproject.toml: Under [tool.setuptools.packages.find] or manually under [tool.setuptools.package-data], include "your_package" = ["py.typed"]. For flit and hatch, include it in the package data manifest.
- Inline types vs. stub files: Inline types (annotations in
.py source files) are the default and preferred approach for most libraries. Stub files (.pyi files alongside .py files, or in a separate stubs package) are appropriate when: the source is C extension code, you are providing types for a third-party package you do not own, or you want to decouple the type interface from the implementation.
- Separate stub packages: Name them
{package-name}-stubs (e.g., requests-stubs). They are separate PyPI distributions. Include a py.typed marker in the stubs package too. Do not include .py source files, only .pyi stubs.
- Stub file completeness: mypy ignores a
.pyi stub if it exists for a module and treats it as the complete interface. If a stub exists and omits a function, callers will see an "attribute not found" error even if the function exists in the .py file. Keep stubs complete or use partial stubs with explicit ... overrides.
- Version your type annotations separately from behavior. Adding type annotations to a library is NOT a breaking change and does not require a major version bump. Changing existing annotations IS a breaking change if you are following semantic versioning for a typed library.
8. Validate and Test the Type System
Type annotations that pass mypy but produce incorrect behavior are worse than no annotations -- they create false confidence.
- Run both mypy and pyright. They have different inference algorithms and catch different classes of errors. A common pattern: use mypy as the CI gate (
mypy --strict src/) and pyright locally via Pylance. Resolve errors in both.
- Use
mypy --strict flag breakdown: --disallow-untyped-defs catches functions missing annotations; --disallow-any-generics catches list without list[int]; --warn-return-any catches functions that return Any; --strict-equality catches comparisons that are always True or False due to types.
- Type-check tests too. Tests are the largest source of untyped Python in most projects. Add
[mypy-tests.*] with disallow_untyped_defs = false to mypy config if you must carve out tests, but ideally type them fully.
- Write
reveal_type based regression tests. Some projects run a test that captures mypy's reveal_type output for key public API functions and asserts it matches expected output. This prevents accidental regression of type information in refactors.
- For library authors: Validate that the annotations you ship are correct using
pyright --verifytypes your_package. This tool gives a percentage of the public API that is fully typed and reports which functions have incomplete annotations.
Output Format
When answering a user question about the Python type system, provide:
1. Problem Diagnosis Block
Typing need: [TypeVar / Protocol / ParamSpec / TypeGuard / overload / TypeVarTuple / assert_never]
Root issue: [What the user is trying to express and why naive annotation fails]
Python version target: [3.8 / 3.9 / 3.10 / 3.11 / 3.12+ -- affects which typing features are available]
Checker implications: [Any specific mypy/pyright behavior or flag to note]
2. Annotated Solution Code
Provide complete, runnable Python code that:
- Has a file-level comment indicating minimum Python version
- Imports from
typing_extensions when supporting Python < 3.10
- Defines TypeVars at module scope with descriptive names and comments explaining bounds/variance
- Includes docstrings on Protocol classes explaining the structural contract
- Uses inline comments to explain non-obvious typing choices
- Compiles without errors under
mypy --strict and pyright --strict
3. Caller Usage Examples
Show how correctly-annotated callsites look, including:
- What IDE autocomplete would infer for return types
- What error messages a caller would see if they pass the wrong type
- At least one negative example (code that correctly fails type checking)
4. Trade-off Notes Table
| Approach |
Type Safety |
Runtime Cost |
Complexity |
Best For |
| [approach 1] |
[high/med/low] |
[ns/μs/ms] |
[low/med/high] |
[use case] |
| [approach 2] |
... |
... |
... |
... |
5. Distribution Checklist (when relevant)
[ ] py.typed marker file present at package root
[ ] py.typed listed in package_data in pyproject.toml
[ ] mypy --strict passes with zero errors
[ ] pyright --verifytypes shows > 95% typed public API
[ ] typing_extensions used for features below minimum Python version
[ ] No typing.List / typing.Dict -- use built-in generics
[ ] TypeVars defined at module scope
[ ] @runtime_checkable Protocols have documented isinstance limitations
Rules
NEVER use bare Any when a more specific type exists. Any is an escape hatch that disables type checking in both directions -- the checker will not validate what you pass in OR what you do with the result. When you find yourself reaching for Any, consider whether object (the safe read-only alternative), a TypeVar with a bound, or a Protocol solves the problem.
NEVER define a TypeVar inside a function body. def f(): T = TypeVar("T") creates a new TypeVar object on every call. Type checkers do not honor function-scoped TypeVars -- they treat them as Any. Define all TypeVars at module scope.
NEVER use cast() to silence a type error. A cast that hides an actual type mismatch introduces a runtime bug that the type system can no longer detect. If you need cast, it signals either a missing Protocol, an incorrectly typed third-party API (fix with a stub or type: ignore with a comment), or a design that needs reconsidering.
ALWAYS use @overload for functions with input-type-dependent return types. Without overload, a function like def parse(data: str | bytes) -> str | bytes forces callers to narrow the return type themselves. With overload, passing str guarantees str return, passing bytes guarantees bytes return. The actual implementation function is NOT type-checked by callers -- only the overload signatures are.
NEVER add arbitrary attributes to a runtime_checkable Protocol and expect isinstance to check them. isinstance(obj, MyProtocol) only checks for the presence of methods and attributes by name, not their types or signatures. A class with def execute(self) -> None satisfies a protocol that declares def execute(self, timeout: int) -> bool from isinstance's perspective. Document this limitation explicitly in code.
ALWAYS use Concatenate when a decorator consumes or injects a leading argument. A decorator that takes request: HttpRequest as the first arg before P must use Callable[Concatenate[HttpRequest, P], R] as its input type to correctly express what it requires and what it exposes.
NEVER mix typing.List, typing.Dict, typing.Tuple, typing.Optional with their built-in equivalents in the same file. Pick the built-in generic syntax (list, dict, tuple, X | None) for Python 3.10+ codebases. Pick the typing imports for Python 3.8/3.9 codebases. Ruff rules UP006 and UP007 enforce this automatically.
ALWAYS verify that covariance and contravariance decisions are semantically correct, not just technically required to satisfy mypy. Declaring a TypeVar covariant when the container actually allows mutation will compile but produce incorrect type behavior for callers -- for example, allowing a MutableStack[Dog] to be assigned to MutableStack[Animal], then inserting a Cat through the Animal reference.
ALWAYS ship py.typed in the package data when distributing a typed library. If py.typed is missing from the wheel, consumers running mypy will treat your library as untyped, ignoring all your inline annotations. Verify with python -c "import importlib.resources; print(list(importlib.resources.files('your_package').iterdir()))" that the file is present after installation.
NEVER use Protocol with @runtime_checkable as a substitute for Pydantic or dataclass validation of external data. Protocol isinstance checks do not validate types of attributes, only their names. Using Protocol for input validation creates a security and correctness hole -- use python-data-modeling patterns for that purpose.
ALWAYS add # type: ignore[import-untyped] (not bare # type: ignore) when suppressing missing stubs. The specific error code documents why the ignore was added and prevents the ignore from silently suppressing other future errors on the same line. Bare # type: ignore suppresses ALL errors on the line including ones you have not seen yet.
When using from __future__ import annotations, audit framework compatibility before enabling. FastAPI, Pydantic v1, and SQLAlchemy rely on get_type_hints() at runtime to process annotations. With PEP 563 active, get_type_hints() must resolve string annotations in the correct namespace, and this fails in some module configurations. Pydantic v2 and FastAPI handle this correctly -- Pydantic v1 does not in all cases.
Edge Cases
TypeVar Escaping Its Generic Scope
Situation: A method inside a generic class uses a TypeVar that shadows the class-level TypeVar, creating two different type variables with the same name. This compiles but produces unexpected behavior.
Handling: Never reuse a TypeVar name inside a class that already binds it at the class level. If class Stack(Generic[T]): already uses T, define a NEW TypeVar (e.g., U = TypeVar("U")) for independent method-level generics. mypy will catch most cases where a class TypeVar is reused incorrectly in a method, but give it a different name to be explicit.
Third-Party Library with No Type Stubs
Situation: A dependency like an internal company library or an older OSS package has no type annotations and no stubs in typeshed. mypy reports error: Skipping analyzing "somelib": module is installed, but missing library stubs or py.typed marker.
Handling: Three options in increasing order of effort:
- Add
[[tool.mypy.overrides]] module = "somelib.*" and ignore_missing_imports = true to suppress the error for that package only. This restores the module to Any but at least localizes the suppression.
- Create a local
stubs/ directory (or typeshed-fallback/) and write minimal stub files (.pyi) for only the functions you use. Point mypy to it with mypy_path = "stubs" in config.
- Contribute stubs to the
typeshed project (for popular libraries) or publish a {package}-stubs package on PyPI. This benefits the entire Python community.
Generic Protocol with Covariant TypeVar That Cannot Be Verified at Runtime
Situation: You define class Repository(Protocol[T_co]): with T_co = TypeVar("T_co", covariant=True) and want to use isinstance for dispatch. The runtime_checkable check cannot verify the generic parameter -- isinstance(repo, Repository) is True for ALL Repositories regardless of their type parameter.
Handling: Do not use generic Protocols with runtime_checkable for type-differentiated dispatch. Instead, use a non-generic runtime_checkable Protocol for the isinstance check, then narrow the generic parameter separately through a .entity_type class attribute or by checking specific method return types with callable inspection.
Overload Signatures That Cover All Cases But mypy Still Complains
Situation: You have written @overload signatures for every possible input, but mypy reports "Overloaded function implementation does not accept all possible arguments."
Handling: The implementation signature must be a supertype of all overload signatures combined. If overloads accept str and bytes, the implementation must accept str | bytes. If overloads use Literal["a"] and Literal["b"], the implementation must accept str. The implementation is NOT visible to external callers -- it only needs to be broad enough to accept all overload input combinations. Add # type: ignore[override] ONLY if you are certain the logic is correct but the checker cannot verify it due to a known limitation.
ParamSpec with Methods on a Generic Class
Situation: You want to use ParamSpec to wrap an instance method, but the self parameter creates complications -- P would capture self as part of the parameters.
Handling: ParamSpec is designed for module-level or standalone function decorators. For method decoration, ParamSpec works but requires careful use of Concatenate to exclude self from the captured parameter set. Callable[Concatenate[MyClass, P], R] captures the method expecting self as the first argument. Alternatively, define the decorator outside the class and apply it to the method -- this is simpler and avoids the Concatenate complexity.
Exhaustive Union Types Growing Over Time
Situation: You have a union Event = LoginEvent | LogoutEvent | ErrorEvent used in many match/case statements across the codebase. A new TimeoutEvent is added to the union. Without tooling, the new case silently falls through to default branches everywhere.
Handling: Use assert_never() in the default branch of every match/case and if/elif chain that handles this union. When TimeoutEvent is added to the union type alias, mypy will immediately report an error at every assert_never() call site because x is narrowed to TimeoutEvent at that point (not Never). This turns a silent runtime bug into a compile-time error. Keep the union alias in one canonical location (e.g., events.py) and use TYPE_CHECKING guards to avoid circular imports when event types are defined across modules.
Gradual Typing in a 50,000+ Line Legacy Codebase
Situation: A large existing Python codebase has no type annotations. Adding mypy --strict produces thousands of errors. The team wants to adopt typing without a multi-month freeze.
Handling: Use a phased approach:
- Phase 1 (weeks 1-4): Add
mypy with zero flags, only ignore_missing_imports = true. Fix all errors that appear without --strict. Establish CI gate at zero mypy errors in default mode.
- Phase 2 (weeks 5-12): Enable
--disallow-untyped-defs for specific high-value modules (public API, core domain logic). Use [[tool.mypy.overrides]] with disallow_untyped_defs = true per-module.
- Phase 3 (ongoing): Add
--strict-equality and --warn-return-any globally. Enable --disallow-any-generics module by module as annotations are added.
- Use
monkeytype (a runtime type inference tool) to generate draft annotations from production traces. Review and refine -- do not use them verbatim. This can reduce the annotation effort by 60-70% for covered code paths.
- Never annotate a module partially. Either annotate all public functions or none -- a partially typed module is worse than an untyped module because it creates false confidence about the unannotated portions.
Self Type for Builder and Fluent Interfaces
Situation: A base class with fluent methods (returning self) causes subclasses to lose their specific type -- MySubclass().set_name("x") returns Base, not MySubclass, breaking the fluent chain.
Handling: In Python 3.11+, import Self from typing and annotate def set_name(self, name: str) -> Self. In Python 3.8-3.10, use typing_extensions.Self (available since typing_extensions 4.0). Do NOT use the old T = TypeVar("T", bound="Base") pattern for this -- it requires the method signature to be def set_name(self: T, name: str) -> T and is more verbose. The Self approach also works correctly with __init_subclass__ and metaclass patterns where the old TypeVar approach breaks.
Example
Input: "I'm building a caching decorator library. I want a @cached decorator that wraps any function, preserves its full type signature for callers, supports an optional ttl: float parameter on the decorator itself, and returns a CachedCallable object that has the same signature as the original function PLUS a .cache_clear() method. How do I type this properly?"
Output:
# Minimum Python version: 3.10
# For Python 3.8/3.9: replace ParamSpec, Concatenate imports with typing_extensions
from __future__ import annotations
import functools
import time
from collections.abc import Callable
from typing import ParamSpec, TypeVar, overload
# Define TypeVars at module scope -- never inside functions
P = ParamSpec("P") # Captures the parameter specification of the wrapped function
R = TypeVar("R") # Captures the return type of the wrapped function
class CachedCallable(Callable[P, R]): # type: ignore[misc]
"""
A callable that wraps an original function with caching behavior.
Has the same signature as the wrapped function (via P and R),
plus a .cache_clear() method and .cache_info() method.
NOTE: Callable cannot be directly subclassed at runtime. This class
is used only as a structural typing target -- we use Protocol below
for the actual type contract.
"""
...
# The correct approach: use Protocol to express the structural contract
# of a callable-plus-methods object. Callable subclassing is not supported
# at runtime in CPython.
from typing import Protocol
class CachedCallable(Protocol[P, R]):
"""
Structural type for a cached callable.
Any object conforming to this protocol:
- Is callable with the same signature as the wrapped function (P -> R)
- Exposes .cache_clear() to invalidate the cache
- Exposes .cache_info() returning hit/miss statistics
"""
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
"""Call the underlying function, returning cached result if available."""
...
def cache_clear(self) -> None:
"""Invalidate all cached results for this callable."""
...
def cache_info(self) -> CacheStats:
"""Return cache hit/miss statistics since last cache_clear()."""
...
from dataclasses import dataclass
@dataclass(frozen=True)
class CacheStats:
"""Immutable snapshot of cache performance statistics."""
hits: int
misses: int
maxsize: int | None
currsize: int
# --- Implementation ---
class _CachedCallableImpl:
"""
Concrete implementation of CachedCallable[P, R].
This class is not directly typed as CachedCallable[P, R] because
Python's runtime generic class machinery does not support ParamSpec
in __class_getitem__. The structural Protocol match is verified by
the type checker without runtime enforcement.
"""
def __init__(
self,
func: Callable[..., R],
ttl: float | None,
) -> None:
self._func = func
self._ttl = ttl
self._cache: dict[tuple[object, ...], tuple[R, float]] = {}
self._hits = 0
self._misses = 0
functools.update_wrapper(self, func)
def __call__(self, *args: object, **kwargs: object) -> R:
# Build a hashable cache key from positional and keyword args.
# kwargs are sorted to normalize key="a", other="b" == other="b", key="a"
cache_key = args + tuple(sorted(kwargs.items()))
now = time.monotonic()
if cache_key in self._cache:
cached_value, cached_at = self._cache[cache_key]
if self._ttl is None or (now - cached_at) < self._ttl:
self._hits += 1
return cached_value
# TTL expired -- fall through to recompute
del self._cache[cache_key]
self._misses += 1
result = self._func(*args, **kwargs)
self._cache[cache_key] = (result, now)
return result
def cache_clear(self) -> None:
self._cache.clear()
self._hits = 0
self._misses = 0
def cache_info(self) -> CacheStats:
return CacheStats(
hits=self._hits,
misses=self._misses,
maxsize=None, # This implementation is unbounded
currsize=len(self._cache),
)
# --- The decorator itself, with two call signatures via @overload ---
#
# The decorator supports two usage patterns:
# @cached -- no arguments, uses default TTL (None = indefinite)
# @cached(ttl=60.0) -- with explicit TTL in seconds
#
# Without @overload, the return type would be ambiguous because
# `cached` returns either a CachedCallable (direct use) or a
# Callable[[Callable[P, R]], CachedCallable[P, R]] (factory use).
@overload
def cached(func: Callable[P, R]) -> CachedCallable[P, R]:
"""Direct decorator usage: @cached"""
...
@overload
def cached(
func: None = None,
*,
ttl: float | None = None,
) -> Callable[[Callable[P, R]], CachedCallable[P, R]]:
"""Factory usage: @cached(ttl=60.0)"""
...
def cached(
func: Callable[P, R] | None = None,
*,
ttl: float | None = None,
) -> CachedCallable[P, R] | Callable[[Callable[P, R]], CachedCallable[P, R]]:
"""
Cache decorator with optional TTL.
Usage:
@cached
def expensive(x: int) -> str: ...
@cached(ttl=30.0)
def time_sensitive(x: int) -> str: ...
"""
def decorator(f: Callable[P, R]) -> CachedCallable[P, R]:
impl = _CachedCallableImpl(f, ttl=ttl)
# The cast here is safe: _CachedCallableImpl structurally satisfies
# CachedCallable[P, R] -- all three methods are present with correct signatures.
# We cannot express this directly because Python's runtime cannot parameterize
# _CachedCallableImpl with P and R.
return impl # type: ignore[return-value] # structural match verified by Protocol
if func is not None:
# Called as @cached directly (no parentheses)
return decorator(func)
# Called as @cached(ttl=...) -- return the decorator factory
return decorator
# --- Usage examples showing what callers see ---
def fetch_user(user_id: int) -> str:
"""Simulated expensive database call."""
return f"User #{user_id}"
# Direct decoration -- mypy infers: CachedCallable[(user_id: int), str]
@cached
def get_user(user_id: int) -> str:
return fetch_user(user_id)
# Factory decoration with TTL
@cached(ttl=30.0)
def get_user_cached(user_id: int) -> str:
return fetch_user(user_id)
# Callers retain full type safety:
result: str = get_user(42) # OK -- user_id: int, returns str
get_user("not_an_int") # mypy error: Argument 1 has incompatible type "str"; expected "int"
get_user.cache_clear() # OK -- method preserved on CachedCallable
stats: CacheStats = get_user.cache_info() # OK -- returns CacheStats
# --- TypeGuard for cache-aware type narrowing ---
# Demonstrates TypeGuard in a realistic context alongside the above
from typing import TypeGuard
def is_cache_stats(value: object) -> TypeGuard[CacheStats]:
"""
Runtime check that value is a CacheStats instance.
TypeGuard means: if this returns True, the type checker narrows
the type of `value` to CacheStats in the True branch ONLY.
The False branch still has type `object`.
Do NOT use this for external data validation -- use Pydantic for that.
This is for internal dispatch where you know the type but the checker does not.
"""
return (
isinstance(value, CacheStats)
and isinstance(value.hits, int)
and isinstance(value.misses, int)
)
def log_if_stats(value: object) -> None:
if is_cache_stats(value):
# In this branch: value is CacheStats -- full attribute access is safe
print(f"Cache: {value.hits} hits, {value.misses} misses, {value.currsize} entries")
else:
# In this branch: value is still `object` -- no narrowing
print(f"Not a CacheStats: {value!r}")
Explanation of key decisions:
Protocol for CachedCallable: A function object with extra attributes (cache_clear, cache_info) cannot be expressed as a subclass of Callable at runtime. Protocol is the correct tool -- it expresses the structural requirement (callable with matching signature, plus two extra methods) without requiring inheritance. The implementation (_CachedCallableImpl) satisfies the Protocol structurally.
**Two @overload si
…(truncated)
1---2name: python-type-system3description: Guides advanced Python type system usage: generic types with TypeVar, Protocol for structural subtyping, ParamSpec for decorator typing, TypeVarTuple for variadic generics, runtime vs static type checking tradeoffs, and py.typed marker for library distribution. Use when the user asks about Python generics, TypeVar, Protocol, ParamSpec, overload decorators, type narrowing, or distributing typed Python libraries. Do NOT use when the user asks about basic type annotations (use `python-idioms`), data validation (use `python-data-modeling`), or project setup (use `python-project-setup`).4license: Apache-2.05---6# Python Type System78## When to Use910**Use this skill when:**11- The user asks about TypeVar, Generic, Protocol, ParamSpec, TypeVarTuple, or Concatenate and needs to understand how to apply them correctly in their codebase12- The user is designing a reusable library component (container class, decorator, utility function) and needs type-safe generic abstractions that work correctly in mypy, pyright, and pyright-based IDEs like VS Code13- The user is creating a structural interface for a plugin system, strategy pattern, or callback-based API where nominal inheritance is not desirable or practical14- The user needs to type a decorator that wraps arbitrary functions while preserving full parameter and return type signatures for callers15- The user asks about type narrowing -- TypeGuard, isinstance branches, assert_never, Literal types in match/case -- and needs to eliminate type errors without resorting to cast()16- The user has multiple overloaded function signatures (e.g., a function that returns str when given str, bytes when given bytes) and needs @overload to express this precisely17- The user is preparing a Python package for distribution on PyPI and needs to understand py.typed markers, inline types vs stub files, and PEP 561 compliance18- The user is migrating a large untyped codebase and needs a strategy for incremental adoption without breaking existing code19- The user is hitting mypy or pyright errors related to variance (invariant containers, covariant return types, contravariant function parameters) and cannot understand why2021**Do NOT use this skill when:**22- The user wants basic variable, parameter, or return type annotations like `def greet(name: str) -> str` -- use `python-idioms` instead23- The user wants to validate untrusted external data (API responses, config files, user input) at runtime -- use `python-data-modeling` for Pydantic or dataclasses-based validation24- The user is setting up a Python project, configuring pyproject.toml, or choosing between mypy and pyright for a new project -- use `python-project-setup`25- The user wants to write tests, including tests that verify type behavior -- use `python-testing-patterns`26- The user is asking about error handling patterns, custom exception hierarchies, or exception chaining -- use `python-error-handling`27- The user asks about data classes specifically for modeling domain entities -- use `python-data-modeling`28- The user is asking about performance optimization of Python code rather than type safety2930---3132## Process3334### 1. Diagnose the Typing Need Precisely3536Before writing any type annotation code, identify which problem the user is actually solving. The wrong tool produces annotations that are technically valid but mislead callers or cause downstream type errors.3738- **Generic container or function with one flexible element type:** Use `TypeVar`. If the type must support comparison, sorting, or arithmetic, add a bound. If the function can only accept `int` or `float` specifically, use constraints (not a bound).39- **Structural interface where you cannot require inheritance:** Use `Protocol`. This is the right choice for third-party extensibility, callback types with multiple parameters, and any time you want duck typing with static verification.40- **Decorator that wraps arbitrary callables:** Use `ParamSpec`. Without it, the wrapper loses all knowledge of the wrapped function's parameters, breaking autocomplete and mypy validation for callers.41- **Function with overloaded signatures that differ in input/output type relationship:** Use `@overload`. The classic cases are: input `str` → output `str`, input `bytes` → output `bytes`; or optional parameter presence controlling return type; or `Literal` values selecting return types.42- **Narrowing from a broad type (like `object` or a union) inside a guard function:** Use `TypeGuard[T]`. If the narrowing is only valid for the `True` branch, use `TypeGuard`. If it is bidirectional (False branch narrows the other way), Python 3.13 added `TypeIs[T]`.43- **Variadic generic (functions that preserve the types of arbitrary-length argument tuples):** Use `TypeVarTuple` with `Unpack`. The canonical use case is a typed `zip` or a function that transforms a heterogeneous tuple while preserving element types.44- **Exhaustive match/case with sum types:** Use `Never` with `assert_never()` from `typing_extensions` or Python 3.11+.4546### 2. Set Up the Typing Environment Correctly4748Type annotations interact with Python version, import style, and checker configuration. Get this right before writing type code.4950- **Python version targeting matters:** `TypeVar`, `Generic`, `Protocol` are in `typing` since Python 3.5. `ParamSpec` and `Concatenate` require Python 3.10+ from `typing` or `typing_extensions` for 3.8+. `TypeVarTuple` and `Unpack` require Python 3.11+ or `typing_extensions >= 4.0`. `TypeIs` requires Python 3.13+ or `typing_extensions >= 4.10`. Always import from `typing_extensions` for backwards compatibility in libraries.51- **Use `from __future__ import annotations` (PEP 563) when:** your file contains forward references (a class referencing itself, or two classes referencing each other), or you want to avoid importing expensive runtime objects just for annotations. Be aware: this makes ALL annotations strings at runtime, which breaks `get_type_hints()` behavior in some frameworks (FastAPI, Pydantic v1). Test your framework compatibility before enabling it globally.52- **Avoid mixing old-style typing imports with built-in generics.** In Python 3.9+, use `list[int]`, `dict[str, int]`, `tuple[int, ...]`, and `type[MyClass]` directly. Do not mix `typing.List[int]` with `list[int]` in the same codebase. Pick one and enforce it with a linter rule (`UP006`, `UP007` in ruff).53- **Configure your type checker strictly for new code:** For mypy, use `strict = true` in `mypy.ini` or `pyproject.toml`. For pyright, use `typeCheckingMode = "strict"`. Understand what these flags enable: `--disallow-untyped-defs`, `--disallow-any-generics`, `--warn-return-any`, `--check-untyped-defs`. Apply strict mode file-by-file using `# mypy: strict` if full-project strict is not yet feasible.5455### 3. Design TypeVar and Generic Class Hierarchies5657TypeVar is the foundation of generic Python code. Most typing bugs come from misusing it.5859- **Define TypeVars at module scope, never inside functions.** A TypeVar defined inside a function is a new object on every call, breaking the identity check that type checkers use to correlate variables.60- **Name TypeVars by convention:** single uppercase `T`, `K`, `V` for simple cases; descriptive names with `_T` suffix for domain-specific vars: `ReturnType_T`, `ElementType_T`. Covariant vars suffix `_co`, contravariant suffix `_contra`.61- **Bound vs. constraints:** `T = TypeVar("T", bound=Comparable)` means "T must be a subtype of Comparable or Comparable itself" -- the generic can receive any conforming type and still return that exact type. `T = TypeVar("T", int, float)` means "T must be exactly int or exactly float" -- the checker picks one per call site. Use bound for protocol-constrained generics; use constraints only when you genuinely need to enumerate the exact allowed types.62- **Variance rules:** A `list[Dog]` is NOT a `list[Animal]` because lists are mutable (invariant). A `Callable[[Animal], None]` IS a `Callable[[Dog], None]` (contravariant in parameters). A `Supplier[Dog]` IS a `Supplier[Animal]` if `Supplier` only produces values (covariant in output). Get variance wrong and mypy will correctly reject assignments that seem intuitive.63- **Covariant containers:** Use `T_co = TypeVar("T_co", covariant=True)` for read-only containers like `Sequence`, `Iterable`, `Iterator`, `Mapping` (keys are invariant, values are covariant). Define `class ImmutableStack(Generic[T_co])` to allow `ImmutableStack[Dog]` to satisfy `ImmutableStack[Animal]`.64- **Self type for fluent APIs and class methods:** In Python 3.11+, use `from typing import Self`. In earlier versions, use `T = TypeVar("T", bound="MyClass")` and annotate `def clone(self: T) -> T`. This ensures subclass methods return the subclass type, not the base class.65- **In Python 3.12+:** use the PEP 695 syntax `def first[T](items: list[T]) -> T` and `class Stack[T]:` instead of the explicit `TypeVar` declaration. This is cleaner and avoids the module-scope naming requirement.6667### 4. Design Protocol Interfaces for Structural Subtyping6869Protocol is the correct tool for any interface that external or third-party code will implement.7071- **Define the minimum viable protocol.** Every method and attribute on a Protocol is a requirement for conformance. Add only what the consumer of the protocol actually uses. A protocol with 12 methods that only uses 2 of them in the consuming function is a design smell.72- **Use Protocol instead of ABC for externally-consumed interfaces.** ABCs require explicit inheritance (`class MyPlugin(PluginBase)`), creating a tight coupling. Protocols require only structural compatibility -- any class with matching methods qualifies. This is more Pythonic and more compatible with code you do not control.73- **Attribute protocols:** Include attributes in protocols with explicit type annotations. The conforming class can implement them as instance variables, properties, or class variables -- the protocol does not care which, only that the attribute is accessible.74- **`runtime_checkable` Protocol is NOT a full runtime type check.** It only checks for method and attribute names via `isinstance()`, not for their signatures or return types. `isinstance(obj, Plugin)` will return `True` for any object with a `name` attribute and `initialize`, `execute`, `shutdown` methods -- even if their signatures are completely wrong. Use it only for duck-typing dispatch, not for validation of untrusted input.75- **Generic Protocols:** Combine Protocol with Generic for parameterized structural interfaces. `class Repository(Protocol[T]):` defines a protocol that is generic in its entity type. Conforming classes must be generic too, or must specialize the TypeVar concretely.76- **Callback protocols:** Use Protocol to type callable objects with specific signatures that cannot be expressed with `Callable`. `class Transformer(Protocol[T, R]):` with `def __call__(self, value: T, *, timeout: float) -> R: ...` expresses a keyword-argument-carrying callable precisely.77- **Protocol inheritance:** Protocols can inherit from other Protocols, creating interface hierarchies without runtime coupling. `class ReadableStream(Protocol):` and `class ReadWriteStream(ReadableStream, Protocol):` composes the interfaces structurally.7879### 5. Type Decorators with ParamSpec and Concatenate8081Decorators are the most commonly mistyped Python construct. Most decorator typing bugs result in the wrapper being typed as `(*args: Any, **kwargs: Any) -> Any`, losing all IDE support for the wrapped function.8283- **The correct ParamSpec pattern:** `P = ParamSpec("P")` and `R = TypeVar("R")`. The decorator receives `Callable[P, R]` and returns `Callable[P, R]`. The wrapper function uses `*args: P.args, **kwargs: P.kwargs` exactly -- these are special forms that only work as `P.args` and `P.kwargs` together.84- **Do NOT add regular parameters between `P.args` in the wrapper signature.** `def wrapper(extra: str, *args: P.args, **kwargs: P.kwargs)` is invalid. If you need to add parameters to the wrapped function's interface, use `Concatenate[ExtraParam, P]` in the outer decorator type.85- **Concatenate pattern:** `def with_auth(func: Callable[Concatenate[AuthToken, P], R]) -> Callable[P, R]` expresses a decorator that consumes the first `AuthToken` argument and exposes the remaining `P` parameters to callers. This correctly types decorators that inject dependencies as the first argument.86- **Class-based decorators with ParamSpec:** When implementing a decorator as a class with `__call__`, annotate `def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R`. The class itself is generic in `P` and `R`.87- **Stacked decorators:** When multiple decorators are applied, each one's `Callable[P, R]` input and output must chain correctly. If a middle decorator changes the signature, its output type must match the next decorator's input type precisely.8889### 6. Apply Type Narrowing Correctly9091Type narrowing is how the type checker tracks type information through conditional branches. Misusing narrowing tools produces silently incorrect type information.9293- **`isinstance` narrowing is the safest and most reliable form.** `if isinstance(x, str):` causes mypy and pyright to narrow `x` to `str` inside the branch. For union types `str | int`, both branches are narrowed correctly. For `object`, narrowing to a concrete type inside the branch is reliable.94- **`TypeGuard[T]` for custom guard functions:** The function must return `bool`. When it returns `True`, the type checker narrows the first parameter to `T`. The narrowing is ONLY applied in the `True` branch -- the `False` branch still has the original type. This is intentional and asymmetric.95- **`TypeIs[T]` (Python 3.13+ or typing_extensions):** Unlike TypeGuard, `TypeIs` narrows in both branches. If `is_str(x)` returns `True`, `x` is `str`. If it returns `False`, `x` is narrowed to "the original type minus str". Prefer `TypeIs` over `TypeGuard` when you have symmetric narrowing.96- **`cast()` is a promise to the type checker that you cannot enforce.** It tells the checker "trust me, this is type T" with zero runtime enforcement. Use it only as an absolute last resort when interacting with dynamically typed code you cannot annotate (e.g., plugin loading via `importlib`, metaclass magic). Document why the cast is safe every time you use it.97- **`assert_never(x)` for exhaustive match:** Import from `typing` (3.11+) or `typing_extensions`. Place it in the default branch of a match statement or if/elif chain over a union. If any case is unhandled, mypy will report an error because `x` will not be narrowed to `Never`. This is the correct pattern for sum types:9899```python100def handle(event: LoginEvent | LogoutEvent | ErrorEvent) -> str:101 match event:102 case LoginEvent():103 return "logged in"104 case LogoutEvent():105 return "logged out"106 case ErrorEvent():107 return "error"108 case _ as unreachable:109 assert_never(unreachable) # mypy error if a new event type is added to the union110```111112- **Literal types for value-based narrowing:** `def process(mode: Literal["read", "write"]) -> None` enables callers to pass only exact string values. Combined with overload, Literal types enable return-type selection based on exact values.113- **`reveal_type(x)`** is a development-only tool (no import needed in mypy; import from `typing` in Python 3.11+ to silence the NameError at runtime). Use it to debug what the type checker currently believes about a variable. Remove before committing.114115### 7. Distribute a Typed Library Correctly (PEP 561)116117A Python library distributed on PyPI must follow PEP 561 to be recognized as typed by mypy, pyright, and other consumers.118119- **Include a `py.typed` marker file.** It is an empty file placed at the root of the package (alongside `__init__.py`). Its presence signals to type checkers that the package has inline type annotations. Without it, mypy defaults to treating the package as untyped (unless the user enables `ignore_missing_imports = false`).120- **Declare the marker in `pyproject.toml`:** Under `[tool.setuptools.packages.find]` or manually under `[tool.setuptools.package-data]`, include `"your_package" = ["py.typed"]`. For flit and hatch, include it in the package data manifest.121- **Inline types vs. stub files:** Inline types (annotations in `.py` source files) are the default and preferred approach for most libraries. Stub files (`.pyi` files alongside `.py` files, or in a separate stubs package) are appropriate when: the source is C extension code, you are providing types for a third-party package you do not own, or you want to decouple the type interface from the implementation.122- **Separate stub packages:** Name them `{package-name}-stubs` (e.g., `requests-stubs`). They are separate PyPI distributions. Include a `py.typed` marker in the stubs package too. Do not include `.py` source files, only `.pyi` stubs.123- **Stub file completeness:** mypy ignores a `.pyi` stub if it exists for a module and treats it as the complete interface. If a stub exists and omits a function, callers will see an "attribute not found" error even if the function exists in the `.py` file. Keep stubs complete or use partial stubs with explicit `...` overrides.124- **Version your type annotations separately from behavior.** Adding type annotations to a library is NOT a breaking change and does not require a major version bump. Changing existing annotations IS a breaking change if you are following semantic versioning for a typed library.125126### 8. Validate and Test the Type System127128Type annotations that pass mypy but produce incorrect behavior are worse than no annotations -- they create false confidence.129130- **Run both mypy and pyright.** They have different inference algorithms and catch different classes of errors. A common pattern: use mypy as the CI gate (`mypy --strict src/`) and pyright locally via Pylance. Resolve errors in both.131- **Use `mypy --strict` flag breakdown:** `--disallow-untyped-defs` catches functions missing annotations; `--disallow-any-generics` catches `list` without `list[int]`; `--warn-return-any` catches functions that return `Any`; `--strict-equality` catches comparisons that are always `True` or `False` due to types.132- **Type-check tests too.** Tests are the largest source of untyped Python in most projects. Add `[mypy-tests.*]` with `disallow_untyped_defs = false` to mypy config if you must carve out tests, but ideally type them fully.133- **Write `reveal_type` based regression tests.** Some projects run a test that captures mypy's `reveal_type` output for key public API functions and asserts it matches expected output. This prevents accidental regression of type information in refactors.134- **For library authors:** Validate that the annotations you ship are correct using `pyright --verifytypes your_package`. This tool gives a percentage of the public API that is fully typed and reports which functions have incomplete annotations.135136---137138## Output Format139140When answering a user question about the Python type system, provide:141142### 1. Problem Diagnosis Block143144```145Typing need: [TypeVar / Protocol / ParamSpec / TypeGuard / overload / TypeVarTuple / assert_never]146Root issue: [What the user is trying to express and why naive annotation fails]147Python version target: [3.8 / 3.9 / 3.10 / 3.11 / 3.12+ -- affects which typing features are available]148Checker implications: [Any specific mypy/pyright behavior or flag to note]149```150151### 2. Annotated Solution Code152153Provide complete, runnable Python code that:154- Has a file-level comment indicating minimum Python version155- Imports from `typing_extensions` when supporting Python < 3.10156- Defines TypeVars at module scope with descriptive names and comments explaining bounds/variance157- Includes docstrings on Protocol classes explaining the structural contract158- Uses inline comments to explain non-obvious typing choices159- Compiles without errors under `mypy --strict` and `pyright --strict`160161### 3. Caller Usage Examples162163Show how correctly-annotated callsites look, including:164- What IDE autocomplete would infer for return types165- What error messages a caller would see if they pass the wrong type166- At least one negative example (code that correctly fails type checking)167168### 4. Trade-off Notes Table169170| Approach | Type Safety | Runtime Cost | Complexity | Best For |171|---|---|---|---|---|172| [approach 1] | [high/med/low] | [ns/μs/ms] | [low/med/high] | [use case] |173| [approach 2] | ... | ... | ... | ... |174175### 5. Distribution Checklist (when relevant)176177```178[ ] py.typed marker file present at package root179[ ] py.typed listed in package_data in pyproject.toml180[ ] mypy --strict passes with zero errors181[ ] pyright --verifytypes shows > 95% typed public API182[ ] typing_extensions used for features below minimum Python version183[ ] No typing.List / typing.Dict -- use built-in generics184[ ] TypeVars defined at module scope185[ ] @runtime_checkable Protocols have documented isinstance limitations186```187188---189190## Rules1911921. **NEVER use bare `Any` when a more specific type exists.** `Any` is an escape hatch that disables type checking in both directions -- the checker will not validate what you pass in OR what you do with the result. When you find yourself reaching for `Any`, consider whether `object` (the safe read-only alternative), a TypeVar with a bound, or a Protocol solves the problem.1931942. **NEVER define a TypeVar inside a function body.** `def f(): T = TypeVar("T")` creates a new TypeVar object on every call. Type checkers do not honor function-scoped TypeVars -- they treat them as `Any`. Define all TypeVars at module scope.1951963. **NEVER use `cast()` to silence a type error.** A cast that hides an actual type mismatch introduces a runtime bug that the type system can no longer detect. If you need cast, it signals either a missing Protocol, an incorrectly typed third-party API (fix with a stub or type: ignore with a comment), or a design that needs reconsidering.1971984. **ALWAYS use `@overload` for functions with input-type-dependent return types.** Without overload, a function like `def parse(data: str | bytes) -> str | bytes` forces callers to narrow the return type themselves. With overload, passing `str` guarantees `str` return, passing `bytes` guarantees `bytes` return. The actual implementation function is NOT type-checked by callers -- only the overload signatures are.1992005. **NEVER add arbitrary attributes to a `runtime_checkable` Protocol and expect isinstance to check them.** `isinstance(obj, MyProtocol)` only checks for the presence of methods and attributes by name, not their types or signatures. A class with `def execute(self) -> None` satisfies a protocol that declares `def execute(self, timeout: int) -> bool` from isinstance's perspective. Document this limitation explicitly in code.2012026. **ALWAYS use `Concatenate` when a decorator consumes or injects a leading argument.** A decorator that takes `request: HttpRequest` as the first arg before `P` must use `Callable[Concatenate[HttpRequest, P], R]` as its input type to correctly express what it requires and what it exposes.2032047. **NEVER mix `typing.List`, `typing.Dict`, `typing.Tuple`, `typing.Optional` with their built-in equivalents in the same file.** Pick the built-in generic syntax (`list`, `dict`, `tuple`, `X | None`) for Python 3.10+ codebases. Pick the `typing` imports for Python 3.8/3.9 codebases. Ruff rules `UP006` and `UP007` enforce this automatically.2052068. **ALWAYS verify that covariance and contravariance decisions are semantically correct, not just technically required to satisfy mypy.** Declaring a TypeVar covariant when the container actually allows mutation will compile but produce incorrect type behavior for callers -- for example, allowing a `MutableStack[Dog]` to be assigned to `MutableStack[Animal]`, then inserting a `Cat` through the `Animal` reference.2072089. **ALWAYS ship `py.typed` in the package data when distributing a typed library.** If `py.typed` is missing from the wheel, consumers running mypy will treat your library as untyped, ignoring all your inline annotations. Verify with `python -c "import importlib.resources; print(list(importlib.resources.files('your_package').iterdir()))"` that the file is present after installation.20921010. **NEVER use `Protocol` with `@runtime_checkable` as a substitute for Pydantic or dataclass validation of external data.** Protocol isinstance checks do not validate types of attributes, only their names. Using Protocol for input validation creates a security and correctness hole -- use `python-data-modeling` patterns for that purpose.21121211. **ALWAYS add `# type: ignore[import-untyped]` (not bare `# type: ignore`) when suppressing missing stubs.** The specific error code documents why the ignore was added and prevents the ignore from silently suppressing other future errors on the same line. Bare `# type: ignore` suppresses ALL errors on the line including ones you have not seen yet.21321412. **When using `from __future__ import annotations`, audit framework compatibility before enabling.** FastAPI, Pydantic v1, and SQLAlchemy rely on `get_type_hints()` at runtime to process annotations. With PEP 563 active, `get_type_hints()` must resolve string annotations in the correct namespace, and this fails in some module configurations. Pydantic v2 and FastAPI handle this correctly -- Pydantic v1 does not in all cases.215216---217218## Edge Cases219220### TypeVar Escaping Its Generic Scope221222**Situation:** A method inside a generic class uses a TypeVar that shadows the class-level TypeVar, creating two different type variables with the same name. This compiles but produces unexpected behavior.223224**Handling:** Never reuse a TypeVar name inside a class that already binds it at the class level. If `class Stack(Generic[T]):` already uses `T`, define a NEW TypeVar (e.g., `U = TypeVar("U")`) for independent method-level generics. mypy will catch most cases where a class TypeVar is reused incorrectly in a method, but give it a different name to be explicit.225226### Third-Party Library with No Type Stubs227228**Situation:** A dependency like an internal company library or an older OSS package has no type annotations and no stubs in typeshed. mypy reports `error: Skipping analyzing "somelib": module is installed, but missing library stubs or py.typed marker`.229230**Handling:** Three options in increasing order of effort:2311. Add `[[tool.mypy.overrides]] module = "somelib.*"` and `ignore_missing_imports = true` to suppress the error for that package only. This restores the module to `Any` but at least localizes the suppression.2322. Create a local `stubs/` directory (or `typeshed-fallback/`) and write minimal stub files (`.pyi`) for only the functions you use. Point mypy to it with `mypy_path = "stubs"` in config.2333. Contribute stubs to the `typeshed` project (for popular libraries) or publish a `{package}-stubs` package on PyPI. This benefits the entire Python community.234235### Generic Protocol with Covariant TypeVar That Cannot Be Verified at Runtime236237**Situation:** You define `class Repository(Protocol[T_co]):` with `T_co = TypeVar("T_co", covariant=True)` and want to use isinstance for dispatch. The runtime_checkable check cannot verify the generic parameter -- `isinstance(repo, Repository)` is `True` for ALL Repositories regardless of their type parameter.238239**Handling:** Do not use generic Protocols with runtime_checkable for type-differentiated dispatch. Instead, use a non-generic runtime_checkable Protocol for the isinstance check, then narrow the generic parameter separately through a `.entity_type` class attribute or by checking specific method return types with callable inspection.240241### Overload Signatures That Cover All Cases But mypy Still Complains242243**Situation:** You have written @overload signatures for every possible input, but mypy reports "Overloaded function implementation does not accept all possible arguments."244245**Handling:** The implementation signature must be a supertype of all overload signatures combined. If overloads accept `str` and `bytes`, the implementation must accept `str | bytes`. If overloads use `Literal["a"]` and `Literal["b"]`, the implementation must accept `str`. The implementation is NOT visible to external callers -- it only needs to be broad enough to accept all overload input combinations. Add `# type: ignore[override]` ONLY if you are certain the logic is correct but the checker cannot verify it due to a known limitation.246247### ParamSpec with Methods on a Generic Class248249**Situation:** You want to use ParamSpec to wrap an instance method, but the `self` parameter creates complications -- `P` would capture `self` as part of the parameters.250251**Handling:** ParamSpec is designed for module-level or standalone function decorators. For method decoration, ParamSpec works but requires careful use of `Concatenate` to exclude `self` from the captured parameter set. `Callable[Concatenate[MyClass, P], R]` captures the method expecting `self` as the first argument. Alternatively, define the decorator outside the class and apply it to the method -- this is simpler and avoids the Concatenate complexity.252253### Exhaustive Union Types Growing Over Time254255**Situation:** You have a union `Event = LoginEvent | LogoutEvent | ErrorEvent` used in many match/case statements across the codebase. A new `TimeoutEvent` is added to the union. Without tooling, the new case silently falls through to default branches everywhere.256257**Handling:** Use `assert_never()` in the default branch of every match/case and if/elif chain that handles this union. When `TimeoutEvent` is added to the union type alias, mypy will immediately report an error at every `assert_never()` call site because `x` is narrowed to `TimeoutEvent` at that point (not `Never`). This turns a silent runtime bug into a compile-time error. Keep the union alias in one canonical location (e.g., `events.py`) and use `TYPE_CHECKING` guards to avoid circular imports when event types are defined across modules.258259### Gradual Typing in a 50,000+ Line Legacy Codebase260261**Situation:** A large existing Python codebase has no type annotations. Adding `mypy --strict` produces thousands of errors. The team wants to adopt typing without a multi-month freeze.262263**Handling:** Use a phased approach:264- Phase 1 (weeks 1-4): Add `mypy` with zero flags, only `ignore_missing_imports = true`. Fix all errors that appear without `--strict`. Establish CI gate at zero mypy errors in default mode.265- Phase 2 (weeks 5-12): Enable `--disallow-untyped-defs` for specific high-value modules (public API, core domain logic). Use `[[tool.mypy.overrides]]` with `disallow_untyped_defs = true` per-module.266- Phase 3 (ongoing): Add `--strict-equality` and `--warn-return-any` globally. Enable `--disallow-any-generics` module by module as annotations are added.267- Use `monkeytype` (a runtime type inference tool) to generate draft annotations from production traces. Review and refine -- do not use them verbatim. This can reduce the annotation effort by 60-70% for covered code paths.268- Never annotate a module partially. Either annotate all public functions or none -- a partially typed module is worse than an untyped module because it creates false confidence about the unannotated portions.269270### `Self` Type for Builder and Fluent Interfaces271272**Situation:** A base class with fluent methods (returning `self`) causes subclasses to lose their specific type -- `MySubclass().set_name("x")` returns `Base`, not `MySubclass`, breaking the fluent chain.273274**Handling:** In Python 3.11+, import `Self` from `typing` and annotate `def set_name(self, name: str) -> Self`. In Python 3.8-3.10, use `typing_extensions.Self` (available since `typing_extensions 4.0`). Do NOT use the old `T = TypeVar("T", bound="Base")` pattern for this -- it requires the method signature to be `def set_name(self: T, name: str) -> T` and is more verbose. The `Self` approach also works correctly with `__init_subclass__` and metaclass patterns where the old TypeVar approach breaks.275276---277278## Example279280**Input:** "I'm building a caching decorator library. I want a `@cached` decorator that wraps any function, preserves its full type signature for callers, supports an optional `ttl: float` parameter on the decorator itself, and returns a `CachedCallable` object that has the same signature as the original function PLUS a `.cache_clear()` method. How do I type this properly?"281282**Output:**283284```python285# Minimum Python version: 3.10286# For Python 3.8/3.9: replace ParamSpec, Concatenate imports with typing_extensions287from __future__ import annotations288289import functools290import time291from collections.abc import Callable292from typing import ParamSpec, TypeVar, overload293294# Define TypeVars at module scope -- never inside functions295P = ParamSpec("P") # Captures the parameter specification of the wrapped function296R = TypeVar("R") # Captures the return type of the wrapped function297298299class CachedCallable(Callable[P, R]): # type: ignore[misc]300 """301 A callable that wraps an original function with caching behavior.302303 Has the same signature as the wrapped function (via P and R),304 plus a .cache_clear() method and .cache_info() method.305306 NOTE: Callable cannot be directly subclassed at runtime. This class307 is used only as a structural typing target -- we use Protocol below308 for the actual type contract.309 """310 ...311312313# The correct approach: use Protocol to express the structural contract314# of a callable-plus-methods object. Callable subclassing is not supported315# at runtime in CPython.316from typing import Protocol317318319class CachedCallable(Protocol[P, R]):320 """321 Structural type for a cached callable.322323 Any object conforming to this protocol:324 - Is callable with the same signature as the wrapped function (P -> R)325 - Exposes .cache_clear() to invalidate the cache326 - Exposes .cache_info() returning hit/miss statistics327 """328329 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:330 """Call the underlying function, returning cached result if available."""331 ...332333 def cache_clear(self) -> None:334 """Invalidate all cached results for this callable."""335 ...336337 def cache_info(self) -> CacheStats:338 """Return cache hit/miss statistics since last cache_clear()."""339 ...340341342from dataclasses import dataclass343344345@dataclass(frozen=True)346class CacheStats:347 """Immutable snapshot of cache performance statistics."""348 hits: int349 misses: int350 maxsize: int | None351 currsize: int352353354# --- Implementation ---355356class _CachedCallableImpl:357 """358 Concrete implementation of CachedCallable[P, R].359360 This class is not directly typed as CachedCallable[P, R] because361 Python's runtime generic class machinery does not support ParamSpec362 in __class_getitem__. The structural Protocol match is verified by363 the type checker without runtime enforcement.364 """365366 def __init__(367 self,368 func: Callable[..., R],369 ttl: float | None,370 ) -> None:371 self._func = func372 self._ttl = ttl373 self._cache: dict[tuple[object, ...], tuple[R, float]] = {}374 self._hits = 0375 self._misses = 0376 functools.update_wrapper(self, func)377378 def __call__(self, *args: object, **kwargs: object) -> R:379 # Build a hashable cache key from positional and keyword args.380 # kwargs are sorted to normalize key="a", other="b" == other="b", key="a"381 cache_key = args + tuple(sorted(kwargs.items()))382383 now = time.monotonic()384 if cache_key in self._cache:385 cached_value, cached_at = self._cache[cache_key]386 if self._ttl is None or (now - cached_at) < self._ttl:387 self._hits += 1388 return cached_value389 # TTL expired -- fall through to recompute390 del self._cache[cache_key]391392 self._misses += 1393 result = self._func(*args, **kwargs)394 self._cache[cache_key] = (result, now)395 return result396397 def cache_clear(self) -> None:398 self._cache.clear()399 self._hits = 0400 self._misses = 0401402 def cache_info(self) -> CacheStats:403 return CacheStats(404 hits=self._hits,405 misses=self._misses,406 maxsize=None, # This implementation is unbounded407 currsize=len(self._cache),408 )409410411# --- The decorator itself, with two call signatures via @overload ---412#413# The decorator supports two usage patterns:414# @cached -- no arguments, uses default TTL (None = indefinite)415# @cached(ttl=60.0) -- with explicit TTL in seconds416#417# Without @overload, the return type would be ambiguous because418# `cached` returns either a CachedCallable (direct use) or a419# Callable[[Callable[P, R]], CachedCallable[P, R]] (factory use).420421422@overload423def cached(func: Callable[P, R]) -> CachedCallable[P, R]:424 """Direct decorator usage: @cached"""425 ...426427428@overload429def cached(430 func: None = None,431 *,432 ttl: float | None = None,433) -> Callable[[Callable[P, R]], CachedCallable[P, R]]:434 """Factory usage: @cached(ttl=60.0)"""435 ...436437438def cached(439 func: Callable[P, R] | None = None,440 *,441 ttl: float | None = None,442) -> CachedCallable[P, R] | Callable[[Callable[P, R]], CachedCallable[P, R]]:443 """444 Cache decorator with optional TTL.445446 Usage:447 @cached448 def expensive(x: int) -> str: ...449450 @cached(ttl=30.0)451 def time_sensitive(x: int) -> str: ...452 """453 def decorator(f: Callable[P, R]) -> CachedCallable[P, R]:454 impl = _CachedCallableImpl(f, ttl=ttl)455 # The cast here is safe: _CachedCallableImpl structurally satisfies456 # CachedCallable[P, R] -- all three methods are present with correct signatures.457 # We cannot express this directly because Python's runtime cannot parameterize458 # _CachedCallableImpl with P and R.459 return impl # type: ignore[return-value] # structural match verified by Protocol460461 if func is not None:462 # Called as @cached directly (no parentheses)463 return decorator(func)464465 # Called as @cached(ttl=...) -- return the decorator factory466 return decorator467468469# --- Usage examples showing what callers see ---470471def fetch_user(user_id: int) -> str:472 """Simulated expensive database call."""473 return f"User #{user_id}"474475476# Direct decoration -- mypy infers: CachedCallable[(user_id: int), str]477@cached478def get_user(user_id: int) -> str:479 return fetch_user(user_id)480481482# Factory decoration with TTL483@cached(ttl=30.0)484def get_user_cached(user_id: int) -> str:485 return fetch_user(user_id)486487488# Callers retain full type safety:489result: str = get_user(42) # OK -- user_id: int, returns str490get_user("not_an_int") # mypy error: Argument 1 has incompatible type "str"; expected "int"491get_user.cache_clear() # OK -- method preserved on CachedCallable492stats: CacheStats = get_user.cache_info() # OK -- returns CacheStats493494495# --- TypeGuard for cache-aware type narrowing ---496# Demonstrates TypeGuard in a realistic context alongside the above497498from typing import TypeGuard499500501def is_cache_stats(value: object) -> TypeGuard[CacheStats]:502 """503 Runtime check that value is a CacheStats instance.504505 TypeGuard means: if this returns True, the type checker narrows506 the type of `value` to CacheStats in the True branch ONLY.507 The False branch still has type `object`.508509 Do NOT use this for external data validation -- use Pydantic for that.510 This is for internal dispatch where you know the type but the checker does not.511 """512 return (513 isinstance(value, CacheStats)514 and isinstance(value.hits, int)515 and isinstance(value.misses, int)516 )517518519def log_if_stats(value: object) -> None:520 if is_cache_stats(value):521 # In this branch: value is CacheStats -- full attribute access is safe522 print(f"Cache: {value.hits} hits, {value.misses} misses, {value.currsize} entries")523 else:524 # In this branch: value is still `object` -- no narrowing525 print(f"Not a CacheStats: {value!r}")526```527528**Explanation of key decisions:**529530**Protocol for `CachedCallable`:** A function object with extra attributes (`cache_clear`, `cache_info`) cannot be expressed as a subclass of `Callable` at runtime. Protocol is the correct tool -- it expresses the structural requirement (callable with matching signature, plus two extra methods) without requiring inheritance. The implementation (`_CachedCallableImpl`) satisfies the Protocol structurally.531532**Two `@overload` si533534…(truncated)