| name | typings |
| description | Use when writing or reviewing Python with strict type-safety enforced: generics with PEP 695 syntax, annotationlib / deferred annotations, Protocol vs ABC, ParamSpec decorators, TypeIs / TypeGuard narrowing, TypedDict / ReadOnly, magic strings / numbers vs Literal / StrEnum / Final, NewType vs aliases, or any time mypy / pyright / pyrefly / ty is configured, run, or failing. |
Python Type-Safety Rules
This project maintains a strict-by-default type-safety bar across every
configured type checker. The exact set is recorded in the consuming project's
tooling config (commonly mypy, pyright, pyrefly, ruff, ty, and a typecheck
target); what matters here is the rule, not the inventory. The cost of a
single Any leak compounds: types decay outward from the leak site,
mechanical refactors become exploratory, and bugs that should fail at the
boundary surface in unrelated modules.
Use the newest typing syntax and semantics supported by the lowest configured
Python version in the project.
The canonical examples in references/canonical-examples.md show accepted
shapes; the decision table in references/decision-trees.md handles common
trade-offs.
This skill is rules, not a tutorial. It assumes familiarity with TypeVar,
Protocol, ParamSpec, TypeGuard, TypeIs, TypedDict, Annotated,
and NewType. It specifies which patterns projects using this skill have
chosen and which they have rejected.
Non-negotiables
- All configured type checkers pass clean. No errors, no warnings, whether
invoked individually (
uv run mypy, uv run pyright, uv run pyrefly,
uv run ty check) or together through the project typecheck target. The set of checkers may grow; the bar does not.
- New typechecker-specific ignore comments require both an error code and an
inline reason. Blanket ignore comments are rejected on review.
Any is forbidden in internal code. It is permitted only at I/O boundaries
such as CLI parsing, JSON ingest, and untyped third-party APIs, and only
with a comment explaining the leak.
- Public function and method signatures are fully annotated, including return
types. Private helpers may omit return annotations only when the body is a
single expression.
- Never weaken a type merely to silence a checker. Classify the relationship
under "Evidence-based typing exceptions" before using a cast, ignore,
Any,
or extra runtime validation.
- Use
Self from PEP 673 for fluent,
builder, and classmethod constructors that return the current class.
When annotating cls explicitly in a classmethod, write
cls: type[Self], not cls: type[BaseClass]; the latter breaks subclass
return inference.
Match forward-reference syntax to the project's lowest configured Python
version.
Evidence-based typing exceptions
Start with the assumption that the configured type checker is correct. Do not
replace a diagnostic with a cast, ignore, Any, or extra runtime validation
until the type relationship has been classified.
| Situation | Required response |
|---|
| The code intentionally violates the declared type, such as a negative test passing a wrong type | Add the narrow typechecker-specific ignore comment, including its diagnostic code and a comment explaining the intentional violation. |
| The value is known to satisfy a runtime invariant, but Python's type system cannot express the relationship without redundant runtime checks | A cast is permissible after the research gate below confirms that the checker is behaving correctly and the relationship is genuinely inexpressible. Do not add runtime checks solely to eliminate the cast. |
| The program is valid but the checker has a confirmed bug | Add the narrow typechecker-specific ignore comment with a comment identifying the bug. Prefer including the upstream issue number or link. |
| The checker is correct and the code or annotation is wrong | Fix the code or type hint. Do not suppress the diagnostic. |
Research gate for casts and suspected checker bugs
Before adding or retaining a cast to bridge a disputed type relationship,
delegate a read-only research task to a subagent. The subagent must:
- Record the project's configured Python target and installed type-checker
version.
- Produce or inspect a minimal example of the same type relationship.
- Check the current Python typing documentation and the current checker's
documentation, issues, tests, and source when necessary.
- Begin from the hypothesis that the checker is correct and that our
annotation or mental model may be wrong.
- Distinguish these outcomes explicitly:
- the checker already supports the relationship and our code or annotations
are wrong;
- Python typing cannot express the proven runtime invariant, so a cast is
appropriate;
- the checker is wrong, confirmed by an upstream issue, test, or maintainer
statement, so a narrow ignore is appropriate.
“This looks like a false positive” is not sufficient evidence. If we believe
the checker is wrong, find an upstream example or counterexample showing that
the maintainers agree it is a bug before adding the typechecker-specific ignore
comment.
Python 3.14 Annotation Semantics
- Do not add
from __future__ import annotations just for forward
references. Python 3.14 uses deferred annotation evaluation by default
under PEP 649 and
PEP 749. Plain forward references in
annotations no longer need string quoting.
- Keep the future import only for compatibility with older supported
interpreters. If a consuming project still supports Python 3.11-3.13,
keep the import consistently until that compatibility target is dropped.
In Python 3.14-only code, new modules should omit it.
- Runtime annotation introspection uses
annotationlib. Code that reads
annotations at runtime must use
annotationlib.get_annotations()
or typing.get_type_hints()
deliberately. Choose annotationlib.Format.VALUE, FORWARDREF, or
STRING based on whether unresolved names should raise, become
ForwardRef values, or remain source strings.
- Do not parse
__annotations__ internals directly. Python 3.14 changed
the runtime model for annotations; direct reads are easy to get wrong when
annotations are deferred or contain unresolved imports.
- Use
TYPE_CHECKING only to avoid runtime imports. Undefined symbols in
annotations are harmless until annotation introspection evaluates them, but
static checkers still need imports guarded by if TYPE_CHECKING: when a
type lives in an expensive or cyclic module.
Project Conventions
- Use PEP 695 generic syntax when
supported by the project's lowest configured Python version.
class Registry[KeyT, ValueT]: and
def first[ItemT](items: Iterable[ItemT]) -> ItemT | None: are preferred
when supported.
Module-level TypeVar / ParamSpec declarations followed by Generic[T]
subclassing are rejected when the newer syntax is supported and ruff UP046
/ UP047 are enabled. The legacy form remains appropriate when required by
the project's Python version, in .pyi stubs, vendored third-party code, and
the rare Protocol variance cases in references/decision-trees.md.
- Type parameter names use the
...T suffix. Prefer KeyT, ValueT,
ItemT, ReturnT, InputT, and OutputT over bare T, K, V, or R.
Single-letter names are reserved for ParamSpec (**P) and variadic type
parameters (*Ts) when the conventional form is clearer.
- Use PEP 696 defaults sparingly.
Defaults for
TypeVar, ParamSpec, and TypeVarTuple are appropriate
when the default preserves the obvious common case. Do not use defaults to
hide ambiguous APIs or to make partial specialization harder to see.
- Protocols for extension contracts. Prefer
Protocol for public and
plugin-facing contracts where independent implementations should satisfy a
structural shape. Use an ABC when shared default behavior, nominal
registration, or runtime subclass checks are part of the design. Do not add
either for one-off internal code when a concrete type is clearer.
@runtime_checkable only when isinstance is genuinely needed. Protocol
conformance is a static check by default; runtime-checkable Protocols are
for plugin loaders and dynamic dispatch, not the default decoration.
- Prefer
collections.abc generics. Import Callable, Iterable,
, , , , , and
similar ABCs from , not deprecated aliases in .
Aliases, NewTypes, and Closed Sets
- Type aliases use the PEP 695
type statement when supported by the project's lowest configured Python
version. Use type UserId = str,
type Cache[KeyT, ValueT] = OrderedDict[KeyT, tuple[ValueT, float]], and
recursive aliases such as type JSONValue = str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]. Module-level
X: TypeAlias = ... remains appropriate when required by the project's
Python version.
NewType for nominal id-like distinctions. Use
UserId = NewType("UserId", str) when the checker must distinguish
UserId from a raw str despite identical runtime representation. Use
type UserId = str only when it is a readability alias.
StrEnum / IntEnum for closed string and integer sets. Module-level
string constants describing one closed set collapse into a StrEnum
subclass with auto(). Use IntEnum for numeric protocol codes and
Flag / IntFlag when bitwise combination is the point.
Literal[...] for ad-hoc closed sets in one signature. Promote to
StrEnum the moment the same set appears in a second signature.
- A
str parameter fed only bare literals is an unlabeled closed set.
Closed sets rarely arrive as declared constants; they enter the code as
inline strings at call sites — step names, states, modes, kinds, event
names — feeding a parameter typed str
(record("result_probe"), publish(state="failed")). That parameter's
real contract is the closed set: type it Literal[...] or StrEnum so a
typo is a checker error instead of a silent runtime miss. Test seams,
instrumentation hooks, and log/metric labels are not exempt.
- Enums are not stringified at call sites. Passing
code=str(ErrorCode.DEADLINE_EXCEEDED) into a code: str parameter
erases the closed set the enum exists to enforce. The signature takes the
enum type (or a union of enum types); conversion to str happens once, at
the serialization boundary.
LiteralString from for
SQL, shell, and format-string parameters. Functions that compose user
input into queries or commands take , not , so the
checker rejects untrusted strings at the call site.
Overrides, Metadata, and Exhaustiveness
@override from PEP 698 on every
override. Pyright flags missing decorators in strict mode. The decorator
catches silent drift when a base method is renamed and the override
accidentally becomes a new method.
Annotated[T, ...] for typed metadata. Pydantic Field, Typer
Option / Argument, and validation or serialization hints must travel
alongside the type. Prefer Annotated[int, Field(ge=0)] over
x: int = Field(ge=0).
Final for module-level constants and class attrs not meant to be
reassigned. Use MAX_RETRIES: Final = 3 and
Final[frozenset[str]] for immutable container constants. Inline numeric
literals with semantic weight — timeouts, caps, retry budgets, thresholds,
protocol codes — are promoted to named Final constants; identity values
and unit conversions (0, 1, * 1000) stay inline.
assert_never in unreachable exhaustive branches. A new variant added
to a discriminated union must break the build at every case _: or final
else, not silently fall through at runtime.
References
references/decision-trees.md - lookup
pairs for ABC vs Protocol, bounded vs constrained type variables, Literal
vs StrEnum, TypeGuard vs TypeIs, and related choices.
references/canonical-examples.md -
strict examples for Registry, Protocol, ParamSpec decorator, narrowing,
error hierarchy, builder, overload, aliases, NewType, StrEnum,
discriminated union, and Annotated + Pydantic.
- Python 3.14
typing docs
and Python 3.14 annotationlib docs
are the runtime references. PEPs are historical design records; prefer the
current docs when they disagree.
Freshness
This skill is project policy, not a complete upstream reference. When applying
it to unfamiliar APIs, version-sensitive behavior, tool/checker disagreement,
or anything that may have changed since the skill was written, verify current
behavior against primary docs. Prefer Context7 MCP when available. If it is
unavailable, use web search restricted to official sources.
Primary sources:
What This Skill Is Not
- Not a Python type-system tutorial. Consult the Python docs for mechanics.
This skill specifies project policy.
- Not the source of truth for tool configuration. In a consuming Python
project, that belongs in
.ruff.toml / pyproject.toml, .mypy.ini,
pyrightconfig.json, and the task runner or Makefile.
- Not exhaustive. New project-specific opinions earn a rule here when they
recur across PRs. One-off judgment calls live in code review, not in this
skill.