Skip to content

Commit b76ce5d

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

1 file changed

Lines changed: 225 additions & 2 deletions

File tree

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

Lines changed: 225 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,195 @@ 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+
201+
## Annotating \_\_init\_\_ methods
202+
203+
[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.
204+
205+
```python
206+
class C1:
207+
# __init__ has no argument is annotated,
208+
# so we should add return type declaration
209+
def __init__(self) -> None:
210+
self.var = 42
211+
212+
class C2:
213+
# __init__ has at least one argument is annotated,
214+
# so it's allowed to omit the return type declaration
215+
# so in most cases, we don't need to add return type.
216+
def __init__(self, arg: int):
217+
self.var = arg
218+
219+
```
220+
221+
## Postponed Evaluation of Annotations
222+
223+
[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:
224+
225+
- [Forward references](https://docs.pydantic.dev/latest/concepts/forward_annotations/)
226+
- [Circular imports](#import-cycles)
227+
- Performance improvements
228+
229+
`from __future__ import annotations` **must be the first executable line** in the file. You can only have shebang and comment lines before it.
230+
231+
```python hl_lines="1 7"
232+
from __future__ import annotations
233+
from pydantic import BaseModel
234+
235+
class User(BaseModel):
236+
name: str
237+
age: int
238+
friends: list[User] = [] # Forward reference works
239+
240+
# This works in Pydantic v2
241+
user = User(name="Alice", age=30, friends=[])
242+
```
243+
244+
!!! warning "from \_\_future\_\_ import annotation is not fully compatible with Pydantic"
245+
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.
246+
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.
247+
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.
248+
249+
## Import cycles
250+
251+
[From MyPy](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#import-cycles): If the cycle import is only needed for type annotations:
252+
253+
```python title="File foo.py" hl_lines="3-6"
254+
from typing import TYPE_CHECKING
255+
256+
if TYPE_CHECKING:
257+
import bar
258+
259+
def listify(arg: 'bar.BarClass') -> 'list[bar.BarClass]':
260+
return [arg]
261+
```
262+
263+
```python title="File bar.py" hl_lines="1"
264+
from foo import listify
265+
266+
class BarClass:
267+
def listifyme(self) -> 'list[BarClass]':
268+
return listify(self)
269+
```
270+
271+
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:
272+
273+
```python title="File models/parent.py" linenums="1" hl_lines="1 8 19-21"
274+
from __future__ import annotations # (1)!
275+
from typing import TYPE_CHECKING, List
276+
from sqlalchemy import String, Integer
277+
from sqlalchemy.orm import Mapped, mapped_column, relationship
278+
from database import Base
279+
280+
if TYPE_CHECKING:
281+
from models.child import Child # (2)!
282+
283+
class Parent(Base):
284+
__tablename__ = "parent"
285+
286+
# SQLAlchemy v2 syntax
287+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
288+
name: Mapped[str] = mapped_column(String(50), nullable=False)
289+
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
290+
291+
# One-to-Many (Parent -> Children)
292+
# children: Mapped[List[Child]] = relationship(
293+
# (3)!
294+
children: Mapped[List["Child"]] = relationship( # (4)!
295+
"Child", # (5)!
296+
back_populates="parent",
297+
cascade="all, delete-orphan",
298+
lazy="selectin" # one of sqlalchemy 2 lazy loading strategies
299+
)
300+
301+
def __repr__(self) -> str:
302+
return f"<Parent(id={self.id}, name='{self.name}')>"
303+
```
304+
305+
1. `from __future__ import annotations` (PEP 563) turns every annotation into a string. Should be used with careful.
306+
307+
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).
308+
309+
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.
310+
311+
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.
312+
313+
5. SQLAlchemy uses string literals (e.g., `"Child"`) to reference models, allowing for lazy loading and avoiding circular dependencies.
314+
315+
```python title="File models/child.py" linenums="1" hl_lines="1 8 24-25"
316+
from __future__ import annotations
317+
from typing import TYPE_CHECKING, Optional
318+
from sqlalchemy import String, Integer, ForeignKey
319+
from sqlalchemy.orm import Mapped, mapped_column, relationship
320+
from database import Base
321+
322+
if TYPE_CHECKING:
323+
from models.parent import Parent
324+
325+
class Child(Base):
326+
__tablename__ = "child"
327+
328+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
329+
name: Mapped[str] = mapped_column(String(50), nullable=False)
330+
age: Mapped[int] = mapped_column(Integer, nullable=False)
331+
332+
parent_id: Mapped[int] = mapped_column(
333+
Integer,
334+
ForeignKey("parents.id", ondelete="CASCADE"),
335+
nullable=False
336+
)
337+
338+
# Many-to-One (Child -> Parent)
339+
parent: Mapped[Parent] = relationship(
340+
"Parent",
341+
back_populates="children",
342+
lazy="selectin"
343+
)
344+
345+
def __repr__(self) -> str:
346+
return f"<Child(id={self.id}, name='{self.name}', parent_id={self.parent_id})>"
347+
```
348+
349+
## Type hints
127350
128351
## Typing tools
129352

0 commit comments

Comments
 (0)