Система типов
В целом можно ожидать, что ty поддерживает все возможности типизации, описанные и заданные в документации по типизации Python (подробный обзор см. в issue по отслеживанию возможностей системы типов). На этой странице выделены некоторые особенности системы типов ty.
Повторные объявления
ty позволяет повторно использовать один и тот же символ с другим типом. В следующем примере параметр paths объявляется заново как список строк:
def split_paths(paths: str) -> list[Path]:
paths: list[str] = paths.split(":")
return [Path(p) for p in paths]
(Полный пример в playground)
Пересечение типов (intersection types)
ty имеет встроенную поддержку типов пересечения. В отличие от объединения A | B («либо A, либо B»), тип пересечения A & B означает «и A, и B». Сужение типов в ty основано на пересечениях. Обратите внимание, как в следующей функции можно вызвать obj.serialize_json() и обратиться к свойству .version:
def output_as_json(obj: Serializable) -> str:
if isinstance(obj, Versioned):
reveal_type(obj) # выводит: Serializable & Versioned
return str({
"data": obj.serialize_json(),
"version": obj.version
})
else:
return obj.serialize_json()
(Полный пример в playground)
Пересечения также можно строить с постепенными типами вроде Any или неявного Unknown. Например, вы вызываете нетипизированный (сторонний) код, который возвращает объект типа Unknown. Сужение типа этого объекта через isinstance даёт тип пересечения Unknown & Iterable. Такой тип позволяет использовать obj как итерируемый объект, но важнее то, что по-прежнему доступны атрибуты исходного неизвестного типа (в примере — .description):
def print_content(data: bytes):
obj = untyped_library.deserialize(data)
if isinstance(obj, Iterable):
print(obj.description)
for part in obj:
print("*", part.description)
else:
print(obj.description)
(Полный пример в playground)
Типы пересечения также используются при сужении через hasattr. В примере ниже тип Person | Animal | None сужается с помощью hasattr(…, "name"). Person остаётся в суженном объединении, так как у него есть атрибут name. Animal пересекается с синтетическим протоколом — учитывается возможность подклассов Animal с членом name. None полностью исключается, так как это финальный тип без атрибута name:
class Person:
name: str
class Animal:
species: str
def greet(being: Person | Animal | None):
if hasattr(being, "name"):
# `being` теперь имеет тип `Person | (Animal & <Protocol with members 'name'>)`
print(f"Hello, {being.name}!")
else:
print("Hello there!")
(Полный пример в playground)
Info
Если в такой ситуации нужно исключить из суженного типа и Animal, можно сделать класс Animal помеченным как @final. Тогда ty сможет вывести более точный тип для being.name (str вместо object).
Если ty — единственный используемый вами проверяющий типов, можно напрямую использовать типы пересечения в аннотациях, импортируя Intersection из специального модуля ty_extensions, который (пока) доступен только во время проверки типов:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ty_extensions import Intersection
type SerializableVersioned = Intersection[Serializable, Versioned]
def output_as_json(obj: SerializableVersioned) -> str:
...
(Полный пример в playground)
Верхняя и нижняя материализации
У постепенных типов обычно есть две специальные материализации. Верхняя материализация — «наибольший» тип, к которому может свестись постепенный тип: объединение всех возможных материализаций. Например, верхняя материализация Any — это object, а верхняя материализация Any & int — int. Для инвариантных универсальных классов верхняя материализация не выражается в системе типов Python, но ty пересекает с ней при проверках isinstance с универсальными классами. Например, при проверке isinstance(…, list) ty пересекает с верхней материализацией list[Unknown]:
@final
class Item: ...
def process(items: Item | list[Item]):
if isinstance(items, list):
# выводит: list[Item]
reveal_type(items)
(Полный пример в playground)
Info
Может возникнуть вопрос, зачем здесь Item объявлен как @final. Без декоратора @final выведенный тип в ветке if станет (Item & Top[list[Unknown]]) | list[Item]. Так учитывается возможность классов, наследующих и от Item, и от list! Если нужно исключить такой случай, можно проверять isinstance по Item. Тогда в ветке else суженный тип будет list[Item] & ~Item, что по сути ведёт себя как list[Item].
Достижимость на основе типов
Анализ достижимости в ty опирается на вывод типов. Это позволяет ty находить недостижимые ветки в большем числе ситуаций по сравнению с подходами, основанными на нескольких известных паттернах (например, проверках sys.version_info >= (3, 10)). У этого есть практическое применение. Рассмотрим код, который должен работать с двумя мажорными версиями зависимости. Следующий код успешно проверяется типом и при установленном pydantic 1.x, и при pydantic 2.x. В обоих случаях ty считает достижимой только соответствующую ветку и не выдаёт ошибок типов для другой. Это возможно, потому что pydantic.__version__.startswith("2.") может быть вычислено в True или False во время проверки типов:
import pydantic
from pydantic import BaseModel
PYDANTIC_V2 = pydantic.__version__.startswith("2.")
class Person(BaseModel):
name: str
def to_json(person: Person):
if PYDANTIC_V2:
return person.model_dump_json() # ошибки не будет при проверке с 1.x
else:
return person.json()
(Полный пример в playground)
Постепенная гарантия
ty в целом стремится не выдавать ложных ошибок типов в нетипизированном коде. Следующий фрагмент не приводит к ошибкам типов при проверке ty (тогда как другие проверяющие типов могут считать, что max_retries имеет тип None, и сообщать об ошибке при присваивании атрибута):
class RetryPolicy:
max_retries = None
policy = RetryPolicy()
policy.max_retries = 1
(Полный пример в playground)
Это достигается тем, что max_retries рассматривается как тип Unknown | None: тип атрибута не известен полностью, но None — одно из возможных значений.
Пользователи могут включить более строгую проверку, добавив аннотации типов (в данном случае — int | None).
Info
Также планируется режим для тех, кто предпочитает по умолчанию более строгий вывод типов в таких ситуациях. Обновления можно отслеживать в этом issue.
Итерация неподвижной точки
Когда тип символа циклически зависит от самого себя, ty использует механизм итерации неподвижной точки, чтобы вывести тип этого символа. В методе tick ниже тип self.value зависит от самого self.value. ty сначала предполагает, что self.value имеет тип Unknown | Literal[0] (тип, выведенный из метода __init__), затем итерирует, пока тип не сойдётся к Unknown | Literal[0, 1, 2, 3, 4]. Без операции взятия по модулю объединение росло бы бесконечно. В таком случае после определённого числа итераций используется откат к типу int.
class LoopingCounter:
def __init__(self):
self.value = 0
def tick(self):
self.value = (self.value + 1) % 5
# выводит: Unknown | Literal[0, 1, 2, 3, 4]
reveal_type(LoopingCounter().value)
(Полный пример в playground)