Skip to content

Commit f3f4541

Browse files
committed
update python type hints
1 parent b6f9aaf commit f3f4541

1 file changed

Lines changed: 223 additions & 2 deletions

File tree

docs/posts/2025/2025-02-01-python-type-hints.md

Lines changed: 223 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ date:
1414

1515
Python is a dynamically typed language, meaning variable types don't require explicit declaration. However, as projects grow in complexity, type annotations become increasingly valuable for code maintainability and clarity.
1616

17-
Type hints have been a major focus of recent Python releases, and I was particularly intrigued when I heard about [Guido van Rossum's work on MyPy at Dropbox](https://blog.dropbox.com/topics/company/thank-you--guido), where the team needed robust tooling to migrate their codebase from Python 2 to Python 3.
17+
Type hints ([PEP 484](https://peps.python.org/pep-0484/)) have been a major focus of recent Python releases, and I was particularly intrigued when I heard about [Guido van Rossum's work on MyPy at Dropbox](https://blog.dropbox.com/topics/company/thank-you--guido), where the team needed robust tooling to migrate their codebase from Python 2 to Python 3.
1818

1919
Today, type hints are essential for modern Python development. They significantly enhance IDE capabilities and AI-powered development tools by providing better code completion, static analysis, and error detection. This mirrors the evolution we've seen with TypeScript's adoption over traditional JavaScript—explicit typing leads to more reliable and maintainable code.
2020

21+
!!! note "Typed Python vs data science projects"
22+
We know that type hints are [not very popular among data science projects](https://engineering.fb.com/2024/12/09/developer-tools/typed-python-2024-survey-meta/) for [some reasons](https://typing.python.org/en/latest/guides/typing_anti_pitch.html), but we won't discuss them here.
23+
2124
<!-- more -->
2225

2326
## typing module vs collections module
@@ -26,6 +29,38 @@ Since Python 3.9, most of types in `typing` module is [deprecated](https://docs.
2629

2730
Some types like: `typing.Any`, `typing.Generic`, `typing.TypeVar`, etc. are still not deprecated.
2831

32+
!!! note "Thanks to subscription support in many collections since Python3.9"
33+
The `collections` module is now the preferred way to import many types (not all yet), as [they support subscription at runtime](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#using-generic-builtins). [Subscription](https://docs.python.org/3/reference/expressions.html#subscriptions) refers to using square brackets `[]` to indicate the type of elements in a collection. **Subscription at runtime** means we can use `list[int]`, `dict[str, int]`, etc. directly without importing from `typing.List`, `typing.Dict`, etc.
34+
```python title="subscription calls \_\_class_getitem\_\_()"
35+
In [1]: list[int]
36+
Out[1]: list[int]
37+
38+
In [2]: type(list[int])
39+
Out[2]: types.GenericAlias
40+
41+
"""
42+
https://docs.python.org/3/reference/datamodel.html#classgetitem-versus-getitem
43+
# Usually, the subscription of an object using square brackets will call the __getitem__() instance method
44+
defined on the object's class. However, if the object being subscribed is itself a class,
45+
the class method __class_getitem__() may be called instead. __class_getitem__()
46+
should return a GenericAlias object if it is properly defined.
47+
"""
48+
In [3]: list.__class_getitem__(int)
49+
Out[3]: list[int]
50+
51+
In [4]: type(list.__class_getitem__(int))
52+
Out[4]: types.GenericAlias
53+
54+
In [5]: list.__getitem__(int)
55+
---------------------------------------------------------------------------
56+
57+
TypeError Traceback (most recent call last)
58+
Cell In[5], line 1
59+
----> 1 list.__getitem__(int)
60+
61+
TypeError: descriptor '__getitem__' for 'list' objects doesn't apply to a 'type' object
62+
```
63+
2964
### Aliases to Built-in Types
3065

3166
| Deprecated Alias | Replacement |
@@ -123,7 +158,193 @@ Some types like: `typing.Any`, `typing.Generic`, `typing.TypeVar`, etc. are stil
123158
| indexing (e.g., `seq[0]`) | Yes | No |
124159
| Membership Checks (`x in data`) | Yes | Yes |
125160

126-
!!! note "(Python 3.9+) Both `typing.Sequence` and `typing.Collection` are [deprecated aliases](#typing-module-vs-collections-module)."
161+
## Type aliases
162+
163+
[From Mypy](https://mypy.readthedocs.io/en/stable/kinds_of_types.html#type-aliases): Python 3.12 introduced the `type` statement for defining explicit type aliases. Explicit type aliases are unambiguous and can also improve readability by making the intent clear.
164+
The definition may contain forward references without having to use string literal escaping, **since it is evaluated lazily**, which improves also the loading performance.
165+
166+
```python
167+
type AliasType = list[dict[tuple[int, str], set[int]]] | tuple[str, list[str]]
168+
169+
# Now we can use AliasType in place of the full name:
170+
171+
def f() -> AliasType:
172+
...
173+
```
174+
175+
## Type variable
176+
177+
[From MyPy](https://mypy.readthedocs.io/en/stable/kinds_of_types.html#the-type-of-class-objects): Python 3.12 introduced new syntax to use the `type[C]` and a type variable with an upper bound (see [Type variables with upper bounds](https://mypy.readthedocs.io/en/stable/generics.html#type-variable-upper-bound)).
178+
179+
```python title="Python 3.12 syntax"
180+
def new_user[U: User](user_class: type[U]) -> U:
181+
# Same implementation as before
182+
```
183+
184+
Here is the example using the legacy syntax (**Python 3.11 and earlier**):
185+
186+
```python title="Python 3.11 and earlier syntax"
187+
U = TypeVar('U', bound=User)
188+
189+
def new_user(user_class: type[U]) -> U:
190+
# Same implementation as before
191+
```
192+
193+
Now mypy will infer the correct type of the result when we call new_user() with a specific subclass of User:
194+
195+
```python
196+
beginner = new_user(BasicUser) # Inferred type is BasicUser
197+
beginner.upgrade() # OK
198+
```
199+
200+
## Annotating \_\_init\_\_ methods
201+
202+
[From MyPy](https://mypy.readthedocs.io/en/stable/class_basics.html#annotating-init-methods): It is allowed to omit the return type declaration on \_\_init\_\_ methods if at least one argument is annotated.
203+
204+
```python
205+
class C1:
206+
# __init__ has no argument is annotated,
207+
# so we should add return type declaration
208+
def __init__(self) -> None:
209+
self.var = 42
210+
211+
class C2:
212+
# __init__ has at least one argument is annotated,
213+
# so it's allowed to omit the return type declaration
214+
# so in most cases, we don't need to add return type.
215+
def __init__(self, arg: int):
216+
self.var = arg
217+
```
218+
219+
## Postponed Evaluation of Annotations
220+
221+
[PEP 563 (Postponed Evaluation of Annotations)](https://peps.python.org/pep-0563/) (also known as Future annotations import) allows you to use `from __future__ import annotations` to defer evaluation of type annotations until they're actually needed. Generally speaking, it turns every annotation into a string. This helps with:
222+
223+
- [Forward references](https://docs.pydantic.dev/latest/concepts/forward_annotations/)
224+
- [Circular imports](#import-cycles)
225+
- Performance improvements
226+
227+
`from __future__ import annotations` **must be the first executable line** in the file. You can only have shebang and comment lines before it.
228+
229+
```python hl_lines="1 7"
230+
from __future__ import annotations
231+
from pydantic import BaseModel
232+
233+
class User(BaseModel):
234+
name: str
235+
age: int
236+
friends: list[User] = [] # Forward reference works
237+
238+
# This works in Pydantic v2
239+
user = User(name="Alice", age=30, friends=[])
240+
```
241+
242+
!!! warning "from \_\_future\_\_ import annotation is not fully compatible with Pydantic"
243+
See this [warning](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#future-annotations-import-pep-563), and see this [github issue](https://github.com/jlowin/fastmcp/issues/905), and [this issue](https://github.com/pydantic/pydantic/issues/2678) for the compatibility issues with Pydantic and postponed evaluation of annotations.
244+
Future annotations import [doesn't support Python3.10 new syntax for union type](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#using-x-y-syntax-for-unions) (e.g., `int | str`), and it also doesn't support the new syntax for type variables with upper bounds (e.g., `type[C]`), neither for some dynamic evaluation of annotations.
245+
So it's preferable **NOT TO USE** `from __future__ import annotation` as much as possible, just use `string literal annotations` for forward references and circular imports.
246+
247+
## Import cycles
248+
249+
[From MyPy](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#import-cycles): If the cycle import is only needed for type annotations:
250+
251+
```python title="File foo.py" hl_lines="3-6"
252+
from typing import TYPE_CHECKING
253+
254+
if TYPE_CHECKING:
255+
import bar
256+
257+
def listify(arg: 'bar.BarClass') -> 'list[bar.BarClass]':
258+
return [arg]
259+
```
260+
261+
```python title="File bar.py" hl_lines="1"
262+
from foo import listify
263+
264+
class BarClass:
265+
def listifyme(self) -> 'list[BarClass]':
266+
return listify(self)
267+
```
268+
269+
SqlAlchemy also uses [string literal](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#string-literal-types-and-type-comments) for lazy evaluation and [typing.TYPE_CHECKING](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#typing-type-checking) for typing:
270+
271+
```python title="File models/parent.py" linenums="1" hl_lines="1 8 19-21"
272+
from __future__ import annotations # (1)!
273+
from typing import TYPE_CHECKING, List
274+
from sqlalchemy import String, Integer
275+
from sqlalchemy.orm import Mapped, mapped_column, relationship
276+
from database import Base
277+
278+
if TYPE_CHECKING:
279+
from models.child import Child # (2)!
280+
281+
class Parent(Base):
282+
__tablename__ = "parent"
283+
284+
# SQLAlchemy v2 syntax
285+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
286+
name: Mapped[str] = mapped_column(String(50), nullable=False)
287+
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
288+
289+
# One-to-Many (Parent -> Children)
290+
# children: Mapped[List[Child]] = relationship(
291+
# (3)!
292+
children: Mapped[List["Child"]] = relationship( # (4)!
293+
"Child", # (5)!
294+
back_populates="parent",
295+
cascade="all, delete-orphan",
296+
lazy="selectin" # one of sqlalchemy 2 lazy loading strategies
297+
)
298+
299+
def __repr__(self) -> str:
300+
return f"<Parent(id={self.id}, name='{self.name}')>"
301+
```
302+
303+
1. `from __future__ import annotations` (PEP 563) turns every annotation into a string. Should be used with careful.
304+
305+
2. The `TYPE_CHECKING` import enables static type checking tools (MyPy, IDEs) to analyze types without affecting runtime behavior. For more details, see the [SQLModel documentation](https://sqlmodel.tiangolo.com/tutorial/code-structure/#import-only-while-editing-with-type_checking).
306+
307+
3. While `from __future__ import annotations` (PEP 563) allows direct usage of `children: Mapped[List[Child]]`, the preferred approach is `children: Mapped[List["Child"]]`. The latter avoids potential compatibility issues with libraries like Pydantic while maintaining clear type hints.
308+
309+
4. By using `if TYPE_CHECKING:`, we ensure the type checker recognizes `children` as a list of `Child` objects (even it's in string format `"Child"`) while preventing circular imports at runtime.
310+
311+
5. SQLAlchemy uses string literals (e.g., `"Child"`) to reference models, allowing for lazy loading and avoiding circular dependencies.
312+
313+
```python title="File models/child.py" linenums="1" hl_lines="1 8 24-25"
314+
from __future__ import annotations
315+
from typing import TYPE_CHECKING, Optional
316+
from sqlalchemy import String, Integer, ForeignKey
317+
from sqlalchemy.orm import Mapped, mapped_column, relationship
318+
from database import Base
319+
320+
if TYPE_CHECKING:
321+
from models.parent import Parent
322+
323+
class Child(Base):
324+
__tablename__ = "child"
325+
326+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
327+
name: Mapped[str] = mapped_column(String(50), nullable=False)
328+
age: Mapped[int] = mapped_column(Integer, nullable=False)
329+
330+
parent_id: Mapped[int] = mapped_column(
331+
Integer,
332+
ForeignKey("parents.id", ondelete="CASCADE"),
333+
nullable=False
334+
)
335+
336+
# Many-to-One (Child -> Parent)
337+
parent: Mapped[Parent] = relationship(
338+
"Parent",
339+
back_populates="children",
340+
lazy="selectin"
341+
)
342+
343+
def __repr__(self) -> str:
344+
return f"<Child(id={self.id}, name='{self.name}', parent_id={self.parent_id})>"
345+
```
346+
347+
## Type hints
127348

128349
## Typing tools
129350

0 commit comments

Comments
 (0)