Перейти к содержанию

Система типов

В целом можно ожидать, что 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 & intint. Для инвариантных универсальных классов верхняя материализация не выражается в системе типов 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)