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 import ensures forward references resolve correctly without runtime overhead.

Static analyzers handle this syntax differently. mypy and pyright fully support PEP 604 in Python 3.10+. ruff automatically rewrites legacy Union imports via UP007. When combining unions with Basic Type Aliases, ensure your alias definitions use the pipe operator consistently to avoid analyzer confusion.

from __future__ import annotations
import sys

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

Run mypy --strict-optional to validate the return path. The analyzer correctly infers str | None without explicit Optional wrappers. Note that ruff will flag typing.Optional usage in 3.10+ projects unless explicitly configured to allow it.

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")
 
 assert isinstance(val, (str, int)) # Narrows to str | int
 
 if isinstance(val, str):
 return int(val)
 return val # Narrowed to int

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. ruff complements these with --select=PYI,UP,PL to catch missing annotations.

Incremental adoption requires targeted overrides. Exclude legacy modules initially, then tighten rules per directory. Pre-commit hooks should run mypy --install-types and pyright --outputjson 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’s ignore_missing_imports bypasses third-party stubs but reduces safety. Prefer disallow_any_explicit to force precise typing. pyright separates reportOptionalMemberAccess from reportOptionalCall, allowing granular CI gating. Run ruff check --fix before committing to auto-format union syntax.

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
 # Implementation logic here
 return {"key": raw}

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. 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 runtime AttributeError exceptions.
  • Inconsistent Union[X, None] and Optional[X] usage: Creates cognitive overhead and complicates automated refactoring tools. Standardize on X | None for modern codebases.
  • Missing None guards before attribute access: Fails strict optional checks and triggers reportOptionalMemberAccess errors. Requires explicit if x is not None: or assert x is not None patterns.

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 # mypy: ignore-errors or pyproject.toml [[tool.mypy.overrides]] to exclude legacy modules initially. Progressively tighten strict = true as you refactor union declarations.