Mastering Union and Optional Types in Python Static Analysis
This guide details the implementation and static analysis behavior when working with composite types. It builds on foundational concepts from Core Type Hints Fundamentals to cover modern syntax migration, strictness tuning, and CI pipeline integration. You will learn to resolve type narrowing issues that trigger false positives in production codebases.
Modern Union Syntax and Backward Compatibility
PEP 604 introduced the X | Y operator, replacing verbose typing.Union[X, Y] declarations. Python 3.10+ evaluates this natively at runtime as types.UnionType. Legacy environments require from __future__ import annotations to defer evaluation — this enables the | syntax in annotation strings on Python 3.7+, but runtime isinstance checks with | still require 3.10+.
Static analyzers handle this syntax consistently. mypy and pyright fully support PEP 604 in Python 3.10+. ruff automatically rewrites legacy Union imports via the UP007 rule. When combining unions with Basic Type Aliases, use the pipe operator consistently to avoid analyzer confusion.
Crucially, both checkers accept the X | Y annotation syntax regardless of your --python-version target as long as from __future__ import annotations is in scope, because a deferred annotation is only a string until something resolves it. What the target version gates is runtime evaluation. With --python-version 3.9, mypy will flag a bare IntOrStr = int | str module-level alias (evaluated eagerly) as error: X | Y syntax for unions requires Python 3.10 [operator], while the same expression inside a def signature under the future import passes. Pyright reports the equivalent as reportGeneralTypeIssues with the message “Alternative syntax for unions requires Python 3.10 or newer” and keys it to the pythonVersion setting in pyrightconfig.json or [tool.pyright].
from __future__ import annotations
def process_payload(data: dict[str, str | int | None]) -> str | None:
if data.get('status') is None:
return None
return str(data['status'])
# mypy --strict-optional correctly infers return type as str | None
# pyright validates native | syntax without typing imports
# ruff UP007 flags legacy Union[X, Y] usage automatically
The three spellings you will encounter are not interchangeable at runtime even though checkers treat them identically. Optional[X] is exactly Union[X, None] — the typing module normalizes it, so typing.get_origin(Optional[int]) returns typing.Union and typing.get_args(Optional[int]) returns (int, NoneType). X | None produces a types.UnionType, whose origin under get_origin is types.UnionType on 3.10+ (not typing.Union), which matters if you write runtime code that branches on the origin. Introspection code that must accept both forms should compare against a tuple:
import types, typing
def is_union(tp: object) -> bool:
origin = typing.get_origin(tp)
return origin is typing.Union or origin is types.UnionType # 3.10+ for the latter
For codebases that still support 3.8 or 3.9, prefer importing the new-syntax helpers from typing_extensions where relevant, and keep Optional/Union imports until you can require 3.10. ruff’s UP007 rewrites Union[X, Y] to X | Y and UP045 rewrites Optional[X] to X | None; both are gated behind your configured target-version, so ruff will not rewrite to a form your minimum interpreter cannot run. A subtle failure mode: applying those rewrites to a module that lacks from __future__ import annotations while still targeting 3.9 turns a passing file into a runtime TypeError at import — always confirm the future import is present (or the target is 3.10+) before accepting the fix.
Both checkers also normalize unions, which removes a class of would-be errors. Order is irrelevant — str | int and int | str are the same type — so a function returning one is assignment-compatible with a signature declaring the other. Nested unions are flattened: (int | str) | bytes collapses to int | str | bytes, and duplicate members are deduplicated. None is order-independent too, so None | str and str | None are identical; teams usually adopt the convention of writing the None member last purely for readability. What checkers will not do is silently collapse bool into int even though bool is a subtype — bool | int stays a two-member union in diagnostics, which occasionally surprises people reading reveal_type output.
One historical wrinkle bites migrating codebases: implicit Optional. PEP 484 originally let def f(x: int = None) mean x: int | None because the default was None. That convenience was deprecated and, since mypy 0.990, no_implicit_optional is the default — so the same signature now raises Incompatible default for argument "x" (default has type "None", argument has type "int"). pyright never supported the implicit form. The fix is explicit: annotate x: int | None = None. mypy ships a --no-implicit-optional flag (and the inverse for legacy code), but the modern default matches pyright, so new code should always spell the None out.
types.UnionType object at runtime in Python 3.10+, while typing.Union[X, Y] creates a typing.Union object — these are different runtime types. Static analyzers treat both forms identically and produce the same error messages for either syntax. The distinction only matters when you inspect union objects at runtime (e.g. with typing.get_args() or isinstance against the union itself).
Type Narrowing and Static Analyzer Workflows
Composite types require explicit control-flow guards. Analyzers track variable states across branches to eliminate impossible types. isinstance() checks trigger reliable narrowing in both mypy and pyright. Using type() instead often fails because it checks exact class identity, ignoring inheritance hierarchies.
Explicit assert x is not None statements force the analyzer to drop None from the union. For complex predicates, typing.TypeGuard (Python 3.10+) and typing.TypeIs (Python 3.13+) provide custom narrowing logic. When narrowing intersects with structural constraints, consult Literal and TypedDict for precise schema validation patterns.
def handle_value(val: str | int | None) -> int:
if val is None:
raise ValueError("Value cannot be None")
if isinstance(val, str):
return int(val)
return val # Narrowed to int
Both checkers narrow on a documented set of patterns: isinstance() and issubclass(), identity checks against None (is None / is not None), equality against Literal values, assert statements, truthiness (if val: removes None and empty-container falsy cases from an Optional), and membership in the tags of a discriminated union. A common surprise is that x == None does not narrow — only the identity form x is None does, because == can be overridden by __eq__ and is therefore not sound for the checker to trust. Similarly, extracting the guard into a helper defeats narrowing unless that helper is annotated as returning TypeGuard[T] (narrows only the positive branch) or TypeIs[T] from PEP 742 (narrows both branches and requires the guarded type to be a subtype of the input):
from typing import TypeIs # typing_extensions on < 3.13
def is_str(v: object) -> TypeIs[str]:
return isinstance(v, str)
def handle(val: str | int) -> None:
if is_str(val):
reveal_type(val) # str
else:
reveal_type(val) # int -- TypeIs narrows the negative branch too
reveal_type(x) is the workhorse for debugging narrowing: mypy prints note: Revealed type is "builtins.int" and pyright prints information: Type of "x" is "int". Neither requires an import — both treat reveal_type as a special form. A frequent narrowing failure is a variable narrowed inside a closure or after an await/function call the checker cannot prove is pure: mypy conservatively widens an attribute like self.value back to its declared type after any method call, so narrow into a local first (v = self.value; if v is not None: ...).
The most powerful narrowing pattern for structured data is the discriminated (tagged) union: a union of types that each carry a common Literal field, which the checker uses as the discriminant. Matching on that field narrows the whole object, with no isinstance needed. Both mypy and pyright implement this, and it composes with TypedDict for JSON-shaped payloads:
from typing import Literal, TypedDict
class Success(TypedDict):
kind: Literal["ok"]
value: int
class Failure(TypedDict):
kind: Literal["err"]
message: str
def render(r: Success | Failure) -> str:
if r["kind"] == "ok":
return str(r["value"]) # narrowed to Success
return r["message"] # narrowed to Failure
The discriminant must be a Literal, not a plain str, or the equality check narrows nothing. See Literal and TypedDict for the schema side of this pattern.
pyright enforces stricter reportUnnecessaryIsInstance checks than mypy. If a guard is redundant, pyright flags it immediately. mypy may silently ignore it unless --warn-unreachable is enabled. Always test guards against both analyzers in CI.
Strictness Tuning and CI Pipeline Integration
Enforcing strict optional checking prevents runtime AttributeError crashes. mypy uses --strict-optional by default in modern versions. pyright requires typeCheckingMode = "strict" in configuration.
Incremental adoption requires targeted overrides. Exclude legacy modules initially, then tighten rules per directory. Pre-commit hooks should run mypy --install-types --non-interactive and pyright to gate merges.
# pyproject.toml
[tool.mypy]
strict = true
warn_return_any = true
disallow_untyped_defs = true
[tool.pyright]
typeCheckingMode = "strict"
reportOptionalMemberAccess = "error"
reportOptionalSubscript = "error"
mypy --strict is a shorthand that switches on a bundle of flags at once — currently warn_unused_configs, disallow_any_generics, disallow_subclassing_any, disallow_untyped_calls, disallow_untyped_defs, disallow_incomplete_defs, check_untyped_defs, disallow_untyped_decorators, warn_redundant_casts, warn_unused_ignores, warn_return_any, no_implicit_reexport, strict_equality, and extra_checks. The exact set grows across releases, which is itself an argument for pinning your mypy version in CI: bumping mypy can add a flag to the --strict bundle and surface new errors on unchanged code. Note strict_optional is on by default and is deliberately not part of what --strict toggles because you already get it; the historical --no-strict-optional still exists for legacy escape but reintroduces the None-compatible-with-everything hole that PEP 484 originally left open and later closed.
For staged rollout, prefer per-module overrides over a global loosening. mypy reads [[tool.mypy.overrides]] blocks keyed by module glob, so you can hold new code to strict = true while granting disallow_untyped_defs = false to a legacy package:
[tool.mypy]
strict = true
[[tool.mypy.overrides]]
module = ["legacy.*", "vendor.thirdparty"]
disallow_untyped_defs = false
warn_return_any = false
Pyright’s analogue is the strict array in pyrightconfig.json (or executionEnvironments for per-directory rules), plus individual reportX diagnostics that can each be set to "error", "warning", or "none". This granularity is why many teams gate on one checker and run the other advisory — see pyright vs mypy and wire both into GitHub Actions type checking. mypy’s ignore_missing_imports bypasses third-party stubs but reduces safety. Prefer disallow_any_explicit = true to force precise typing on explicit Any annotations. pyright separates reportOptionalMemberAccess from reportOptionalCall, allowing granular CI gating.
In the pipeline itself, both checkers signal failure purely through their exit code — mypy exits 1 on any error, pyright exits 1 on any reported diagnostic — so no extra scripting is needed to fail a build; the anti-pattern is masking that status with mypy . || true. Because the union rules above are version-sensitive (a bare int | str runtime alias is legal on 3.10 but a [operator] error on 3.9), pass --python-version to mypy — or run a version matrix — so the target your users run is the target you check. Pin the checker versions in your lockfile: because --strict gains flags across releases, an unpinned pip install mypy can redden an unchanged PR the day a new mypy ships. Running the same pinned commands locally through pre-commit hooks keeps CI and developer machines reporting identical optional-checking diagnostics.
Debugging False Positives and Overly Broad Unions
Union explosion occurs when signatures accumulate | None across multiple layers. This degrades analyzer performance and obscures intent. Refactor Optional[Any] immediately — it disables narrowing and guarantees runtime failures. Replace it with precise structural types or protocol definitions.
Conditional return types require typing.overload. Define specific signatures for each input variant before falling back to a generic implementation. This pattern satisfies mypy’s strict return checking and prevents pyright from reporting ambiguous type inference. For syntax standardization across teams, reference How to use typing.Optional vs Union in Python 3.10+ to enforce consistent code review standards.
from typing import overload
@overload
def parse_config(raw: str) -> dict[str, str]: ...
@overload
def parse_config(raw: None) -> None: ...
def parse_config(raw: str | None) -> dict[str, str] | None:
if raw is None:
return None
return {"key": raw}
The reason Optional[Any] is uniquely harmful is that Any is both a supertype and a subtype of everything, so str | Any simplifies to Any and the union effectively vanishes. mypy will not emit [union-attr] on val.nonexistent() because every attribute access on Any is permitted; the false negative only becomes a AttributeError at runtime. Turn on warn_return_any and disallow_any_explicit so an accidental Any is a hard error rather than a silent hole. When you genuinely mean “any object,” annotate object instead — it forces callers to narrow before use, which is what you wanted from the union in the first place.
Overload bodies have their own failure modes worth knowing. mypy checks that the implementation signature is compatible with every @overload stub and raises [misc] “Overloaded function implementation does not accept all possible arguments” when it is not; it also flags overlapping overloads with incompatible return types as [overload-overlap]. pyright reports the parallel reportOverlappingOverloads and reportInconsistentOverload. A common mistake is forgetting that the implementation def must not carry @overload and must come last, and that on Python < 3.11 you cannot use @overload on a method without also importing it — typing_extensions.overload backports the runtime-inspectable version used by get_overloads().
To keep a union honest as it evolves, add an exhaustiveness check on the else branch with typing.assert_never. When every member has been narrowed away, the residual type is Never; assert_never accepts only Never, so adding a new member to the union without handling it becomes a compile-time error rather than a silent fall-through:
from typing import assert_never # typing_extensions on < 3.11
def describe(val: str | int | None) -> str:
if val is None:
return "empty"
if isinstance(val, str):
return f"text:{val}"
if isinstance(val, int):
return f"number:{val}"
assert_never(val) # mypy/pyright error here if a member is left unhandled
If someone later widens the parameter to str | int | float | None, mypy reports Argument 1 to "assert_never" has incompatible type "float"; expected "Never" and pyright reports reportArgumentType, pointing straight at the unhandled case. This turns “overly broad union” from a runtime risk into a checked invariant, and it is the single most effective guard against the union-explosion failures this section warns about.
Debug unresolved branches by running mypy --show-error-codes. pyright provides --verbose output detailing narrowing steps. Use # type: ignore[union-attr] only when third-party libraries lack stubs — the bracketed code is required under mypy’s warn_unused_ignores, which will itself flag a # type: ignore that no longer suppresses anything as [unused-ignore]. pyright’s equivalent inline suppression is # pyright: ignore[reportOptionalMemberAccess], and it too warns on stale suppressions when reportUnnecessaryTypeIgnoreComment is enabled. Track suppressions in a dedicated audit file to prevent technical debt accumulation.
Common Pitfalls
- Using
Optional[Any]instead of precise types: Masks underlying type ambiguity and disables static analyzer narrowing. Causes false negatives in CI pipelines and runtimeAttributeErrorexceptions. - Inconsistent
Union[X, None]andOptional[X]usage: Creates cognitive overhead and complicates automated refactoring tools. Standardize onX | Nonefor modern codebases. - Missing
Noneguards before attribute access: Fails strict optional checks and triggersreportOptionalMemberAccesserrors. Requires explicitif x is not None:orassert x is not Nonepatterns.
FAQ
Should I use X | None or Optional[X] in Python 3.10+?
Use X | None for new codebases targeting Python 3.10+. It aligns with PEP 604, reduces verbosity, and is natively supported by modern static analyzers without requiring typing imports.
How do I fix reportOptionalMemberAccess errors in CI?
Add explicit None guards (if obj is not None:) or assert obj is not None before accessing attributes. This satisfies control-flow narrowing requirements in pyright and mypy.
Can I enforce strict optional checking incrementally?
Yes. Use [[tool.mypy.overrides]] sections in pyproject.toml to exclude legacy modules initially. Progressively tighten strict = true as you refactor union declarations.