Learning Platform
Глоссарий Troubleshooting
Урок 08.05 · 20 мин
Средний
Type narrowingisinstanceTypeGuardPEP 647TypeIsPEP 742assert_typeNullable handling

Type narrowing и TypeGuard — сужение типов в runtime

Type narrowing — механизм mypy / pyright “сужать” union types к specific type внутри branch’а. Если функция возвращает int | None, и вы написали if x is None: return; ..., type checker знает что в остатке функции x: int (already None excluded). Это — fundamental механизм, без которого union types были бы бесполезны (нельзя вызвать x.bit_length() если x: int | None без narrow’инга).

Большинство narrowing — automatic (isinstance, is None, ==, match patterns). Иногда нужно custom narrowing — вы знаете predicate, но mypy не может сам вывести. Тогда — TypeGuard[T] (PEP 647, Python 3.10) — user-defined narrowing function.

В этом уроке:

  1. Why narrowingint | Noneint через if is None: return.
  2. isinstance(x, T) narrowing — most common pattern.
  3. assert isinstance(...) — narrowing + runtime check.
  4. TypeGuard[T] — custom narrowing function (PEP 647).
  5. assert_type — mypy hint (prose-only).
  6. Forward note — TypeIs (PEP 742, Python 3.13) — improved TypeGuard.
  7. Recipe — nullable handling pattern.

Why narrowing — int | Noneint

Простая функция возвращающая int | None:

def parse_age(s: str) -> int | None:
    try:
        return int(s)
    except ValueError:
        return None


age = parse_age('42')
# age: int | None — в этой точке type checker не знает что age — int
# age + 1                                        # mypy error: Unsupported operand types for + ("None" and "int")

if age is None:
    print('invalid')
else:
    # age: int — None исключён через narrowing
    print(age + 1)                               # OK

Что происходит на уровне type checker:

  1. Initial: age: int | None (return type parse_age).
  2. if age is None: — narrowing predicate. Внутри if body: age: None. После else:: age: int (None excluded from union).
  3. В else: body: age + 1 OK потому что age: int (narrowed).

Это — automatic narrowing. mypy / pyright знают patterns is None, is not None, isinstance(x, T), ==, !=, assert, match — и применяют их bidirectionally. Best practice: всегда explicitly narrow nullable values до использования — иначе type checker подсветит warning.


isinstance(x, T) narrowing

Standard pattern для разделения union types по runtime type:

def stringify(x: int | str | float) -> str:
    """Convert any of int/str/float to formatted string."""
    if isinstance(x, int):
        # x: int — narrowed
        return f'int({x})'
    if isinstance(x, str):
        # x: str — narrowed
        return f'str({x!r})'
    # x: float — последний remaining type union
    return f'float({x:.2f})'


print(stringify(42))         # int(42)
print(stringify('hello'))    # str('hello')
print(stringify(3.14))       # float(3.14)

mypy bidirectional inference: после isinstance(x, int)x: int в if body; после isinstance(x, str)x: str. После всех narrowing branches — последний remaining type автоматически известен.

isinstance(x, A | B) также работает (Python 3.10+, благодаря types.UnionType):

def is_numeric(x: object) -> bool:
    return isinstance(x, int | float)        # combines int OR float check


print(is_numeric(42))         # True
print(is_numeric(3.14))       # True
print(is_numeric('hello'))    # False
print(is_numeric(True))       # True (bool is int subclass!)

Cite: PEP 604 — isinstance(x, X | Y) runtime support; M01 урок 03 cross-link — bool is int subclass (Python data model).


assert isinstance(...) — narrowing + runtime check

Иногда вы знаете что value определённого типа (например, после deserialization), но mypy не может вывести. assert isinstance(...) — narrowing и runtime guard:

import json


def parse_config(raw: str) -> dict[str, int]:
    """Parse config; expect dict[str, int]. Assert structure."""
    data = json.loads(raw)
    # data: Any (json.loads returns Any)

    assert isinstance(data, dict), f'expected dict, got {type(data).__name__}'
    # data: dict — narrowed после assert

    out: dict[str, int] = {}
    for k, v in data.items():
        assert isinstance(k, str), f'expected str key, got {type(k).__name__}'
        assert isinstance(v, int), f'expected int value, got {type(v).__name__}'
        # k: str, v: int — both narrowed
        out[k] = v
    return out


print(parse_config('{"port": 8080, "timeout": 30}'))    # {'port': 8080, 'timeout': 30}

# Bad input:
# parse_config('[1, 2, 3]')                             # AssertionError: expected dict, got list
# parse_config('{"port": "not-int"}')                   # AssertionError: expected int value, got str

Pattern: assert isinstance(x, T) сразу после deserialization / external input. Это runtime contract + mypy narrowing в одной строке.

Pitfall: assert стирается с python -O flag (optimization mode). Для production-grade validation используйте if not isinstance(...): raise TypeError(...) — gets stripped never.


TypeGuard[T] — custom narrowing function (PEP 647)

Иногда узнавание type требует complex predicate — несколько isinstance checks или structural test. mypy не может сам вывести что вернувшая True функция narrowed argument к specific type. Решение — TypeGuard[T]:

from typing import TypeGuard


def is_str_list(items: list[object]) -> TypeGuard[list[str]]:
    """Custom narrowing: True iff all items are str."""
    return all(isinstance(x, str) for x in items)


def process(items: list[object]) -> str:
    if is_str_list(items):
        # items: list[str] — narrowed!
        return ', '.join(items)
    return 'mixed types'


print(process(['a', 'b', 'c']))      # a, b, c
print(process(['a', 1, 'c']))        # mixed types

Что special про TypeGuard[T]:

  1. Return type TypeGuard[T] — signal mypy / pyright: “если эта функция returns True, аргумент имеет тип T”.
  2. Runtime — обычный boolTypeGuard[T] → runtime True/False.
  3. isinstance semantics user-defined — вы пишете predicate, type checker применяет narrowing.

Без TypeGuard mypy не может вывести что после is_str_list(items) returning True — items имеют тип list[str]. Это главная мотивация PEP 647.

# WITHOUT TypeGuard:
def is_str_list_naive(items: list[object]) -> bool:
    return all(isinstance(x, str) for x in items)


def process_naive(items: list[object]) -> str:
    if is_str_list_naive(items):
        # items: list[object] — НЕ narrowed!
        # ', '.join(items)                      # mypy error: object не str
        return 'cannot narrow'
    return ''

bool return type не triggered narrowing — mypy не знает predicate intent. TypeGuard[T] решает это.

Cite: PEP 647 — User-Defined Type Guards; Lib/typing.pyTypeGuard class.


assert_type — mypy hint (prose-only)

typing.assert_type(x, T)mypy assertion: “ожидаю что x имеет тип T в этой точке”. Если mypy не согласен — error. Runtime — no-op (returns x as-is).

from typing import assert_type


def parse_age(s: str) -> int | None:
    try:
        return int(s)
    except ValueError:
        return None


age = parse_age('42')
assert_type(age, int | None)        # mypy: OK (runtime no-op)

if age is not None:
    assert_type(age, int)            # mypy: OK (narrowed by `is not None`)


# Misuse:
# assert_type(age, str)              # mypy error: expected str, got int | None

Use case: debugging type inference — добавьте assert_type(x, expected_type) чтобы verify mypy view’ит type как вы думаете. Production: не используется (no-op runtime). Test: полезно в type-stub testing.

Cite: PEP 749 — Implementing PEP 649; typing.assert_type (Python 3.11+).


Forward note — TypeIs (PEP 742)

PEP 742 (Python 3.13) — improved TypeGuard: TypeIs[T]. Difference от TypeGuard:

AspectTypeGuard[T] (PEP 647)TypeIs[T] (PEP 742)
True branchx: T (narrowed)x: T (narrowed)
False branchunchangedx: X - T (T removed from union)!
AsymmetryYesNo (symmetric)
# Concept (Python 3.13+):
# from typing import TypeIs
#
# def is_str(x: int | str) -> TypeIs[str]:
#     return isinstance(x, str)
#
# def f(x: int | str) -> None:
#     if is_str(x):
#         reveal_type(x)        # str — narrowed
#     else:
#         reveal_type(x)        # int — narrowed (T removed from union)

TypeIs — symmetric. False branch также narrows (removes T from union). Это closer к isinstance semantics. Для большинства cases — более интуитивный чем TypeGuard.

Forward note для курса: в M07 не используем (Pyodide ships Python 3.12 — TypeIs not available). Когда Python 3.13+ поднимется в Pyodide → стоит prefer TypeIs над TypeGuard. Это carrying decision forward к v2 курса.

Cite: PEP 742 — Narrowing types with TypeIs (Python 3.13).


Diagram: 4 narrowing mechanisms

Type narrowing — четыре механизма
if x is None: returnautomaticmypy / pyright knows: после `if x is None: return` оставшаяся часть функции имеет `x: T` (None removed). Standard idiomatic для nullable handling. Не требует import. Cite: PEP 484 + mypy docs
isinstance(x, T)automaticif isinstance(x, int): body — `x: int`. Works для union split: int | str | float → 3 isinstance branches. PEP 604: isinstance(x, A | B) Python 3.10+. Bool is int subclass — pitfall. Cite: PEP 484 + PEP 604
TypeGuard[T] (PEP 647)user-defineddef is_str_list(items: list[object]) -> TypeGuard[list[str]]: ... Возвращает bool runtime, signal mypy: True branch → `items: list[str]`. Asymmetric: False branch unchanged. PEP 742 TypeIs (Python 3.13) — symmetric improvement. Cite: PEP 647 + PEP 742
assert isinstance(x, T)runtime + narrowingПосле `assert isinstance(x, T)` — mypy narrows + runtime AssertionError если type не match. Pitfall: assert стирается `python -O`. Production-grade — `if not isinstance(x, T): raise TypeError(...)`. Pattern для post-deserialization validation

Recipe: nullable handling pattern

End-to-end production pattern — функция работает с user input через nullable chain:

import json


def get_config_value(raw: str, key: str) -> int:
    """Parse JSON config, extract integer value at key. Default 0 if missing/invalid."""

    # Parse — Any return type
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return 0

    # Narrow #1: dict
    if not isinstance(data, dict):
        return 0
    # data: dict

    # Narrow #2: key existence
    if key not in data:
        return 0

    # Narrow #3: value type
    value = data[key]
    if not isinstance(value, int):
        return 0
    # value: int

    return value


# Test:
print(get_config_value('{"port": 8080}', 'port'))           # 8080
print(get_config_value('{"port": 8080}', 'missing'))        # 0  (key missing)
print(get_config_value('{"port": "string"}', 'port'))       # 0  (wrong type)
print(get_config_value('not-json', 'port'))                 # 0  (bad parse)
print(get_config_value('[1, 2]', 'port'))                   # 0  (not a dict)

Каждый narrow — separate if block с early return. mypy следит за всеми paths. Цепочка narrows = defensive programming + type-safe runtime.

Альтернатива через try/except:

def get_config_value_v2(raw: str, key: str) -> int:
    """Same — exception-based version."""
    try:
        data = json.loads(raw)
        return int(data[key])           # raises KeyError, TypeError, ValueError
    except (json.JSONDecodeError, KeyError, TypeError, ValueError):
        return 0

Оба паттерна работают. if isinstance chain — explicit type narrowing, mypy-friendly. try/except — concise, но catches potentially unrelated errors. Choice — style preference.


Ключевые выводы

  1. Type narrowing — type checker “сужает” union types в branches. Без narrowing union types бесполезны (нельзя вызвать method специфичного для type).
  2. Automatic narrowingis None/is not None, isinstance(x, T), ==/!=, match patterns. mypy / pyright handle these без explicit declaration.
  3. isinstance(x, A | B) — Python 3.10+ runtime support через types.UnionType (PEP 604). Combines два type checks.
  4. assert isinstance(x, T) — narrowing + runtime check. Pitfall: stripped с python -O. Production: if not isinstance(...): raise TypeError(...).
  5. TypeGuard[T] (PEP 647) — user-defined narrowing function. Return type TypeGuard[T], runtime — bool. Asymmetric: True branch narrows, False unchanged.
  6. TypeIs[T] (PEP 742, Python 3.13) — improved TypeGuard. Symmetric — обе branches narrow. Closer к isinstance semantics. Forward-link к v2 курса (Pyodide ships 3.12 currently).
  7. assert_type(x, T) — mypy hint for debugging type inference. Runtime no-op. Не для production.
  8. Recipe: chain narrows для nullable / external input handling — explicit if isinstance + early return — mypy-friendly defensive programming.

Дальше — урок 06 — typed exceptions (PYTH-09 home): class CustomError(Exception):, try/except (A, B) as e:, else, finally, ExceptionGroup (PEP 654 forward note).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Какой код **корректно narrows** `x: int | None` к `int` для использования арифметики?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7