Mastering typing.Self and typing.NotRequired for Python Static Analysis
This guide details the implementation of Advanced Typing Patterns & Generics by focusing on typing.Self and typing.NotRequired. It provides actionable workflows for Python 3.11+, strictness tuning for CI pipelines, and debugging steps for modern static analyzers.
- Transition from legacy
TypeVarbounds to PEP 673Selffor accurate subclass return types. - Use PEP 655
NotRequiredfor explicit optionalTypedDictfields withoutOptionaltype pollution. - Reference Using typing.Self for fluent interfaces for builder pattern optimization.
- Configure mypy and pyright to enforce strict compliance in CI/CD workflows.
Implementing typing.Self for Fluent Method Chaining
Legacy patterns relied on TypeVar("T", bound="Base") to preserve subclass types across chained methods. PEP 673 introduces typing.Self to eliminate this boilerplate. When you annotate a return type with Self, static analyzers automatically resolve it to the concrete subclass at each call site. This integrates seamlessly with broader Generics and TypeVar constraints when mixing generic containers.
# Python 3.11+ native. Use typing_extensions for 3.10 compatibility.
from typing import Self
class BaseBuilder:
def set_name(self, name: str) -> Self:
self.name = name
return self
class ExtendedBuilder(BaseBuilder):
def set_version(self, ver: int) -> Self:
self.version = ver
return self
# ExtendedBuilder().set_name("app").set_version(2) preserves ExtendedBuilder type
Mechanically, Self behaves like an implicit TypeVar that is bound to the enclosing class and solved from the type of self at each call site. That is why ExtendedBuilder().set_name("app") resolves to ExtendedBuilder rather than BaseBuilder, keeping a subclass-only method such as .set_version() reachable further down the chain. The pre-PEP-673 idiom required you to write self: T on every chainable method; omit it on a single method and the inferred type silently widens back to the base, so the next call fails with mypy’s [attr-defined] (“BaseBuilder has no attribute set_version”). Self removes that footgun because there is nothing per-method to forget.
Self was added to typing in Python 3.11. On 3.8–3.10 import it from typing_extensions; the two refer to the same special form at type-check time, so from typing_extensions import Self is recognised identically by mypy and pyright. Because Self is only meaningful inside a class body, using it at module scope or on a bare function is rejected — mypy reports [misc] (“Self is only allowed in annotations within a class definition”), and pyright emits a comparable reportGeneralTypeIssues. Note that a method annotated -> Self must actually return self (or type(self)(...)); returning a fresh base instance such as return BaseBuilder() is flagged [return-value] (“Incompatible return value type”), because a base object is not guaranteed to be the caller’s concrete subclass.
The same annotation also types alternative constructors and context managers precisely: @classmethod def open(cls) -> Self and def __enter__(self) -> Self both keep the subclass identity, which is exactly what fluent and RAII-style APIs need. Unlike the old string-bound TypeVar("T", bound="BaseBuilder"), Self needs no forward reference and is unaffected by whether you use from __future__ import annotations (PEP 563). See Using typing.Self for fluent interfaces for a full builder walkthrough, and typing.Self vs TypeVar return types for the migration comparison.
Self also nests. A method typed def clones(self, n: int) -> list[Self] returns a list whose element type tracks the subclass, so ExtendedBuilder().clones(3) is inferred list[ExtendedBuilder]; the same holds inside dict[str, Self], Iterator[Self], or a tuple[Self, Self]. It works unchanged in async def methods — async def refresh(self) -> Self awaited on a subclass still resolves to that subclass — and it is exactly the annotation a Protocol needs to describe a “returns itself” method structurally. One boundary worth remembering: Self refers to the runtime class of the receiver, not the class in which the method is textually defined, which is precisely why a base-class method written once serves every subclass correctly.
Structuring TypedDict with typing.NotRequired and typing.Required
Traditional TypedDict schemas forced developers to choose between total=True (all keys required) and total=False (all keys optional). PEP 655 resolves this with Required and NotRequired. These markers explicitly control key presence without polluting value types with Optional[T] unions.
from typing import TypedDict, Required, NotRequired
class UserPayload(TypedDict, total=False):
id: Required[int]
username: Required[str]
email: NotRequired[str]
role: NotRequired[str]
# Static analyzers enforce id/username presence, while email/role are strictly optional
The key insight is that totality and per-key markers compose in both directions. A bare class T(TypedDict) is total=True, so every key is required; NotRequired then carves out individual optional keys. Declaring total=False inverts the default — every key is optional — and Required pins the ones that must always be present, as above. You can therefore express any mix of required and optional keys regardless of which totality you start from, which is what the old all-or-nothing model could not do without splitting the schema into two TypedDicts joined by inheritance.
Version and import notes matter here. TypedDict itself is PEP 589 and has been in typing since Python 3.8, but Required/NotRequired are PEP 655 and only reached typing in 3.11 — on 3.8–3.10 import them (and TypedDict, if you want the newer features) from typing_extensions. At runtime a TypedDict is just a dict subclass: the markers are entirely erased, so UserPayload(id=1, username="a") simply builds a plain dict and a missing required key is a static error ([typeddict-item]), never a KeyError at construction. Reading a NotRequired key with subscript syntax such as payload["email"] is accepted by both mypy and pyright as type str, yet it can raise KeyError at runtime because the key may be absent; prefer payload.get("email") (typed str | None) or an if "email" in payload: guard, which both checkers narrow correctly.
Finally, keep the two axes distinct. NotRequired controls whether a key may be missing; Optional (i.e. X | None) controls whether a value may be None. They are orthogonal and frequently combined: email: NotRequired[str | None] means the key may be absent and, when present, may hold None. In strict mode both checkers enforce presence of every Required key at the call site; pyright’s strict mode is somewhat more aggressive about flagging possibly-absent keys during partial updates and **kwargs unpacking, so a schema that passes mypy may still need a .get() guard to satisfy pyright.
A closely related PEP 705 marker, ReadOnly, arrived in typing_extensions (and typing from Python 3.13) and composes with these two: id: ReadOnly[Required[int]] declares a key that must be present and may not be reassigned through the TypedDict alias, which is useful when a payload is shared across mutating call sites. This is also the mechanism behind safe width subtyping — a TypedDict with a ReadOnly key can be treated as a supertype of one that narrows that key’s value type. If you are choosing between a TypedDict and a @dataclass for a structured payload, see when to use TypedDict vs dataclasses; TypedDict wins when the data is already a JSON-shaped dict and you want annotations without changing its runtime type.
Self nor NotRequired affects runtime behaviour. typing.Self is erased at runtime — Python does not enforce return types. NotRequired only influences static checkers; a plain dict with or without the key is accepted at runtime regardless of the annotation.
CI Pipeline Integration and Strictness Tuning
Enforcing modern typing standards requires precise CI configuration. Align mypy.ini or pyproject.toml with strict baselines to catch subtle type drift early.
[tool.mypy]
strict = true
warn_unused_ignores = true
enable_error_code = ["ignore-without-code"]
[tool.pyright]
typeCheckingMode = "strict"
reportMissingTypeStubs = true
The strict = true switch is an umbrella: it turns on disallow_untyped_defs, disallow_any_generics, warn_return_any, no_implicit_optional, warn_redundant_casts, and several more, so you rarely need to list them individually. Pin python_version = "3.11" (or your minimum target) explicitly, because the checker gates availability of Self, Required, and NotRequired on it — set it to "3.10" and mypy will reject from typing import Self even when your local interpreter is newer, which is exactly what you want CI to model. For code that still supports 3.10, import those names from typing_extensions and keep python_version at the real floor. See mypy configuration strictness for an incremental rollout that avoids a wall of errors on day one.
Run the two checkers as independent gates rather than one, since they disagree at the margins and catch different mistakes. Configure pre-commit hooks to run targeted checks on modified files only, reducing CI latency in large repositories: pyright --outputjson emits machine-readable diagnostics for annotating pull requests, while mypy’s --junit-xml flag integrates cleanly with GitHub Actions test reporting (see GitHub Actions type checking). Enable mypy’s incremental cache (.mypy_cache/, on by default) and persist it between CI runs to cut wall-clock time on large trees; pyright is already incremental in watch mode. Add warn_unused_ignores = true and enable_error_code = ["ignore-without-code"] so that stale # type: ignore comments — common after a migration removes the underlying error — themselves become failures, keeping suppressions honest.
Global strictness is rarely all-or-nothing during a migration. Scope relaxations to the modules that need them with a per-module override rather than lowering the whole baseline:
[[tool.mypy.overrides]]
module = "legacy.builders.*"
disallow_untyped_defs = false
pyright’s equivalent is a per-directory pyrightconfig.json or an # pyright: basic comment at the top of a file. Keep reportMissingTypeStubs on so untyped third-party imports surface as [import-untyped] (mypy) rather than silently becoming Any and eroding the guarantees Self and NotRequired give you; add stubs or a targeted ignore instead of disabling the check globally.
Debugging Complex Type Errors and Migration Workflows
Migrating legacy codebases to PEP 673 and PEP 655 often triggers false positives. Self in a @classmethod is not wrong on its own — -> Self as a return type is exactly right for a factory — but annotating the first parameter as cls: Self is: the receiver of a classmethod is the class object, so it must be typed cls: type[Self], and mypy reports [misc] if you write cls: Self. A @staticmethod has no self/cls at all, so Self cannot resolve there and should be replaced with the explicit class name or a bound TypeVar.
Confusion frequently arises between NotRequired and Optional. NotRequired controls dictionary key presence; Optional (that is, X | None) defines value nullability. A NotRequired[str] field expects a string if provided — it never accepts None. A NotRequired[str | None] field is both optional to include and nullable when present. A common migration bug is “fixing” a missing-key error by widening the value to Optional[str], which silences nothing about presence and instead forces every reader to handle a None that never occurs; the correct change is NotRequired[str], leaving the value type clean.
When Self cannot resolve a complex conditional return — for example a method that returns self in one branch and a wrapped result in another — leverage @overload to define explicit signature branches, since a single Self annotation cannot describe two different return types.
Use reveal_type() strategically during debugging. Insert it before and after method calls to trace static analyzer inference paths: reveal_type(builder) prints Revealed type is "…" (mypy) or an information diagnostic (pyright) to stdout, and neither call exists at runtime, so leaving one in temporarily is safe. When mypy and pyright disagree on an inferred Self, compare both reveal_type outputs before changing the annotation — the divergence is usually about how far each engine propagates the subclass through generics, not about PEP 673 itself.
Common Mistakes
Most Self/NotRequired regressions are one of a handful of small annotation slips. The two most frequent are shown side by side below — the flagged form on the left, the corrected form on the right.
- Using
typing.Selfin@classmethodwithout the correct annotation: For@classmethod, the return type-> Selfis correct, but the first parameter must becls: type[Self], notcls: Self.Selfdenotes an instance of the class; a classmethod receives the class object, socls: Selfis flagged[misc]by mypy andreportGeneralTypeIssuesby pyright. - Confusing
typing.NotRequiredwithtyping.Optional:NotRequiredcontrols key presence in aTypedDict.Optionalcontrols whether a value can beNone. They address different dimensions and are often combined:NotRequired[str | None]. Reaching forOptionalto “make a key optional” leaves the key required while forcing every reader through a spuriousNonecheck. - Omitting
typing_extensionsfallback for Python <3.11: BothSelf(PEP 673) andRequired/NotRequired(PEP 655) reachedtypingonly in Python 3.11. On 3.8–3.10 import them fromtyping_extensionsto preventImportError, and set the checker’spython_versionto your real floor so CI models the older runtime rather than your newer local interpreter. - Subscripting a
NotRequiredkey without a guard:payload["email"]type-checks asstrbut can raiseKeyErrorat runtime when the key is absent. Usepayload.get("email")(typedstr | None) or anif "email" in payload:narrowing guard instead.
FAQ
Can I use typing.Self with Python 3.9 or 3.10?
Yes, by importing Self from typing_extensions (from typing_extensions import Self). The runtime behaviour is identical — both resolve to the same special form — and mypy and pyright recognise the backport seamlessly. Keep the checker’s python_version at your real floor so it still rejects from typing import Self on 3.10, which is the failure your users would hit.
Does typing.NotRequired replace Optional entirely?
No. NotRequired defines optional key presence in a TypedDict; Optional[T] (i.e. T | None) defines a nullable value. They address different dimensions and are frequently combined — NotRequired[str | None] is a key that may be absent and may hold None when present. Neither one is a substitute for the other.
Why does mypy report “Self” is not defined in strict mode?
Ensure you are on Python 3.11+ or have installed typing_extensions and imported Self from it; a bare from typing import Self fails when python_version is set below 3.11. Also confirm strict mode isn’t masking an import error from missing stubs or an incorrect mypy.ini path. Remember Self is only valid inside a class body — using it on a module-level function is a separate [misc] error, not an import problem.