docs/features/type-system.md
You can generally expect ty to support all typing features that are described and specified in the Python typing documentation (for a detailed overview, please refer to the type system features tracking issue). This page highlights some of the unique features that ty's type system provides.
ty allows you to reuse the same symbol with a different type. The following example shows how the
paths parameter is redeclared as a list of strings:
def split_paths(paths: str) -> list[Path]:
paths: list[str] = paths.split(":")
return [Path(p) for p in paths]
(Full example in the playground)
ty has first-class support for intersection types. In contrast to a union type A | B, which means
"either A or B", an intersection type A & B means "both A and B". Type narrowing in ty is
based on intersections. For example, notice how we can call obj.serialize_json() and access the
.version property in the following function:
def output_as_json(obj: Serializable) -> str:
if isinstance(obj, Versioned):
reveal_type(obj) # reveals: Serializable & Versioned
return str({
"data": obj.serialize_json(),
"version": obj.version
})
else:
return obj.serialize_json()
(Full example in the playground)
Intersections can also be built using gradual types like Any or its implicit counterpart
Unknown. For example, imagine you call into untyped (third party) code that returns an object of
type Unknown. Narrowing the type of that object using isinstance will result in an intersection
type Unknown & Iterable. This type allows you to use obj as an iterable. But more importantly,
it still gives you access to attributes defined on the original unknown type (.description, in this
example):
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)
(Full example in the playground)
Intersection types are also used in hasattr narrowing. Take a look at the following example where
we narrow a type of Person | Animal | None using hasattr(…, "name"). Person is preserved in
the narrowed union type because it has a name attribute. Animal is intersected with a synthetic
protocol, accounting for the possibility of subclasses of Animal that add a name member.
None is excluded completely since it is a final type that has no name attribute:
class Person:
name: str
class Animal:
species: str
def greet(being: Person | Animal | None):
if hasattr(being, "name"):
# `being` is now of type `Person | (Animal & <Protocol with members 'name'>)`
print(f"Hello, {being.name}!")
else:
print("Hello there!")
(Full example in the playground)
!!! info
If you run into a situation like this and would like `Animal` to be excluded from the narrowed
type as well, you can make `Animal` a `@final` class. This also allows ty to infer a more precise
type for `being.name` (`str` instead of `object`).
If ty is the only type checker you use, you can also make direct use of intersection types in
annotations by importing Intersection from the special ty_extensions module that is (currently)
only available at type-checking time:
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:
...
(Full example in the playground)
Gradual types generally have two special materializations. The top materialization represents the
"largest" type that a gradual type can materialize to: the union of all possible materializations.
For example, the top materialization of Any is object, and the top materialization of
Any & int is int. For invariant generic classes, the top materialization cannot be expressed in
Python's type system, but it is a useful type that ty intersects with when isinstance checks
involve generic classes. For example, when checking isinstance(…, list), ty intersects with the
top materialization of list[Unknown]:
@final
class Item: ...
def process(items: Item | list[Item]):
if isinstance(items, list):
# reveals: list[Item]
reveal_type(items)
(Full example in the playground)
!!! info
You might wonder why `Item` is declared `@final` here. If we remove the `@final` decorator, the
inferred type in the `if` branch becomes `(Item & Top[list[Unknown]]) | list[Item]` instead.
This accounts for the possibility of classes that inherit from both `Item` *and* `list`! If
you run into this situation and want to rule out this case, you can also perform the `isinstance`
check against `Item` instead. The `else` branch will then have a narrowed type of
`list[Item] & ~Item`, which effectively acts like `list[Item]`.
Reachability analysis in ty is based on type inference. This allows ty to detect unreachable
branches in many more situations compared to approaches which match on a few known patterns (e.g.
sys.version_info >= (3, 10) checks). This has useful practical applications. Consider a case
where you are writing code that needs to be compatible with two major versions of a dependency.
The following code can be successfully type-checked with either pydantic 1.x installed, or pydantic
2.x installed. In both cases, ty will only consider the corresponding branch to be reachable, and
will not emit any type errors for the other branch. This works because
pydantic.__version__.startswith("2.") can be evaluated to True or False at type-checking time:
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() # no error here when checking with 1.x
else:
return person.json()
(Full example in the playground)