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.

Self and NotRequired type mechanics Left panel shows a method chain on ExtendedBuilder where Self resolves to ExtendedBuilder, not BaseBuilder. Right panel shows a TypedDict with Required and NotRequired keys, indicating which keys must be present. typing.Self BaseBuilder.set_name() → Self returns caller's concrete type ExtendedBuilder().set_name("x") inferred: ExtendedBuilder ✓ Legacy TypeVar("T", bound=Base) inferred: Base ✗ (too wide) PEP 673 — Python 3.11+ typing.NotRequired class Config(TypedDict, total=False): id: Required[int] ← must include timeout: NotRequired[float] ← may omit {"id": 1} ✓ {"id": 1, "timeout": 30.0} ✓ {} ✗ id required PEP 655 — Python 3.11+
Self resolves to the caller's concrete subclass; NotRequired marks individual TypedDict keys as optional without using Optional on the value type.
  • Transition from legacy TypeVar bounds to PEP 673 Self for accurate subclass return types.
  • Use PEP 655 NotRequired for explicit optional TypedDict fields without Optional type 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.

Self preserves the subclass along a fluent chain Starting from ExtendedBuilder, each Self-returning method keeps the inferred type as ExtendedBuilder rather than widening to BaseBuilder. Self never widens to BaseBuilder along the chain ExtendedBuilder() concrete instance .set_name("app") -> Self infers ExtendedBuilder .set_version(2) -> Self infers ExtendedBuilder result type: ExtendedBuilder PEP 673 — resolved per call site (Python 3.11+, or typing_extensions on 3.8-3.10)
Each Self-returning step keeps the inferred type at ExtendedBuilder; the concrete subclass survives the whole chain.
# 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.

Required and NotRequired key presence in a TypedDict Each row of UserPayload marks a key Required or NotRequired, and three sample dicts show which validate against those rules. UserPayload(TypedDict, total=False) id: Required[int] must include username: Required[str] must include email: NotRequired[str] may omit role: NotRequired[str] may omit PEP 655 — per-key, no Optional pollution {"id": 1, "username": "a"} valid {"id": 1, "username": "a", "email": "x@y"} valid {"id": 1} # username missing rejected mypy: [typeddict-item] on the missing required key · pyright: reportGeneralTypeIssues
Required keys must appear in every literal; NotRequired keys may be absent, and a checker rejects any dict that drops a required key.
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.

Runtime vs static analysis Neither 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.

Strict config rises through mypy and pyright gates to a passing pipeline A shared strict configuration feeds a pre-commit stage, then parallel mypy strict and pyright strict gates, and CI passes only when both agree. pyproject.toml — strict / typeCheckingMode "strict" pre-commit — changed files only mypy --strict --junit-xml pyright --strict --outputjson CI passes
One strict config drives both checkers; the pipeline goes green only when mypy and pyright agree.
[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.

Debugging Self and NotRequired migration errors A root question branches to four common symptoms — classmethod receiver, staticmethod, NotRequired versus Optional, and conditional returns — each pointing to its fix. Self / NotRequired error after migration? classmethod cls typed Self? staticmethod no instance key still errors NotRequired vs Optional conditional return branches use cls: type[Self] name class explicitly key presence ≠ None value split with @overload
Four recurring migration symptoms and the fix each one calls for.

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.

Before and after for two common Self and NotRequired mistakes The left column shows the flagged code — cls typed Self and a key made Optional — and the right column shows the fixes using type[Self] and NotRequired. Before — flagged After — correct def make(cls: Self) -> Self mypy [misc]: cls is the class def make(cls: type[Self]) -> Self factory keeps the subclass email: Optional[str] still required, now nullable email: NotRequired[str] key may be absent, value clean
Type the classmethod receiver as type[Self], and make an optional key NotRequired rather than widening its value to Optional.
  • Using typing.Self in @classmethod without the correct annotation: For @classmethod, the return type -> Self is correct, but the first parameter must be cls: type[Self], not cls: Self. Self denotes an instance of the class; a classmethod receives the class object, so cls: Self is flagged [misc] by mypy and reportGeneralTypeIssues by pyright.
  • Confusing typing.NotRequired with typing.Optional: NotRequired controls key presence in a TypedDict. Optional controls whether a value can be None. They address different dimensions and are often combined: NotRequired[str | None]. Reaching for Optional to “make a key optional” leaves the key required while forcing every reader through a spurious None check.
  • Omitting typing_extensions fallback for Python <3.11: Both Self (PEP 673) and Required/NotRequired (PEP 655) reached typing only in Python 3.11. On 3.8–3.10 import them from typing_extensions to prevent ImportError, and set the checker’s python_version to your real floor so CI models the older runtime rather than your newer local interpreter.
  • Subscripting a NotRequired key without a guard: payload["email"] type-checks as str but can raise KeyError at runtime when the key is absent. Use payload.get("email") (typed str | None) or an if "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.

Back to Advanced Typing Patterns & Generics