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.
В этом уроке:
- Why narrowing —
int | None→intчерезif is None: return. isinstance(x, T)narrowing — most common pattern.assert isinstance(...)— narrowing + runtime check.TypeGuard[T]— custom narrowing function (PEP 647).assert_type— mypy hint (prose-only).- Forward note — TypeIs (PEP 742, Python 3.13) — improved TypeGuard.
- Recipe — nullable handling pattern.
Why narrowing — int | None → int
Простая функция возвращающая 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:
- Initial:
age: int | None(return typeparse_age). if age is None:— narrowing predicate. Внутриifbody:age: None. Послеelse::age: int(None excluded from union).- В
else:body:age + 1OK потому что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]:
- Return type
TypeGuard[T]— signal mypy / pyright: “если эта функция returns True, аргумент имеет тип T”. - Runtime — обычный
bool—TypeGuard[T]→ runtimeTrue/False. isinstancesemantics 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.py — TypeGuard 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:
| Aspect | TypeGuard[T] (PEP 647) | TypeIs[T] (PEP 742) |
|---|---|---|
| True branch | x: T (narrowed) | x: T (narrowed) |
| False branch | unchanged | x: X - T (T removed from union)! |
| Asymmetry | Yes | No (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
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.
Ключевые выводы
- Type narrowing — type checker “сужает” union types в branches. Без narrowing union types бесполезны (нельзя вызвать method специфичного для type).
- Automatic narrowing —
is None/is not None,isinstance(x, T),==/!=,matchpatterns. mypy / pyright handle these без explicit declaration. isinstance(x, A | B)— Python 3.10+ runtime support черезtypes.UnionType(PEP 604). Combines два type checks.assert isinstance(x, T)— narrowing + runtime check. Pitfall: stripped сpython -O. Production:if not isinstance(...): raise TypeError(...).TypeGuard[T](PEP 647) — user-defined narrowing function. Return typeTypeGuard[T], runtime —bool. Asymmetric: True branch narrows, False unchanged.TypeIs[T](PEP 742, Python 3.13) — improved TypeGuard. Symmetric — обе branches narrow. Closer кisinstancesemantics. Forward-link к v2 курса (Pyodide ships 3.12 currently).assert_type(x, T)— mypy hint for debugging type inference. Runtime no-op. Не для production.- 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).