Skip to content

Commit d89aefe

Browse files
committed
improve python typing hints
1 parent b76ce5d commit d89aefe

1 file changed

Lines changed: 121 additions & 122 deletions

File tree

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

Lines changed: 121 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ categories:
77
comments: true
88
date:
99
created: 2025-02-01
10-
updated: 2025-06-28
10+
updated: 2025-07-09
1111
---
1212

1313
# Python Type Hints
@@ -25,13 +25,13 @@ Today, type hints are essential for modern Python development. They significantl
2525

2626
## typing module vs collections module
2727

28-
Since Python 3.9, most of types in `typing` module is [deprecated](https://docs.python.org/3/library/typing.html#deprecated-aliases), and `collections` module is recommended.
28+
Since Python 3.9, most of types in `typing` module i [deprecated](https://docs.python.org/3/library/typing.html#deprecated-aliases), and `collections` module is recommended.
2929

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

3232
!!! note "Thanks to subscription support in many collections since Python3.9"
3333
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\_\_()"
34+
```python title="subscription calls **class_getitem**()"
3535
In [1]: list[int]
3636
Out[1]: list[int]
3737

@@ -163,58 +163,57 @@ Some types like: `typing.Any`, `typing.Generic`, `typing.TypeVar`, etc. are stil
163163
[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.
164164
The definition may contain forward references without having to use string literal escaping, **since it is evaluated lazily**, which improves also the loading performance.
165165

166-
```python
167-
type AliasType = list[dict[tuple[int, str], set[int]]] | tuple[str, list[str]]
166+
```python
167+
type AliasType = list[dict[tuple[int, str], set[int]]] | tuple[str, list[str]]
168168

169-
# Now we can use AliasType in place of the full name:
169+
# Now we can use AliasType in place of the full name:
170170

171-
def f() -> AliasType:
172-
...
173-
```
171+
def f() -> AliasType:
172+
...
173+
```
174174

175175
## Type variable
176176

177177
[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)).
178178

179-
```python title="Python 3.12 syntax"
180-
def new_user[U: User](user_class: type[U]) -> U:
181-
# Same implementation as before
182-
```
179+
```python title="Python 3.12 syntax"
180+
def new_user[U: User](user_class: type[U]) -> U:
181+
# Same implementation as before
182+
```
183183

184184
Here is the example using the legacy syntax (**Python 3.11 and earlier**):
185185

186-
```python title="Python 3.11 and earlier syntax"
187-
U = TypeVar('U', bound=User)
186+
```python title="Python 3.11 and earlier syntax"
187+
U = TypeVar('U', bound=User)
188188

189-
def new_user(user_class: type[U]) -> U:
190-
# Same implementation as before
191-
```
189+
def new_user(user_class: type[U]) -> U:
190+
# Same implementation as before
191+
```
192192

193193
Now mypy will infer the correct type of the result when we call new_user() with a specific subclass of User:
194194

195-
```python
196-
beginner = new_user(BasicUser) # Inferred type is BasicUser
197-
beginner.upgrade() # OK
198-
199-
```
195+
```python
196+
beginner = new_user(BasicUser) # Inferred type is BasicUser
197+
beginner.upgrade() # OK
198+
```
200199

201200
## Annotating \_\_init\_\_ methods
202201

203202
[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.
204203

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
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
211210

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
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
218217

219218
```
220219

@@ -228,18 +227,18 @@ Now mypy will infer the correct type of the result when we call new_user() with
228227

229228
`from __future__ import annotations` **must be the first executable line** in the file. You can only have shebang and comment lines before it.
230229

231-
```python hl_lines="1 7"
232-
from __future__ import annotations
233-
from pydantic import BaseModel
230+
```python hl_lines="1 7"
231+
from __future__ import annotations
232+
from pydantic import BaseModel
234233

235-
class User(BaseModel):
236-
name: str
237-
age: int
238-
friends: list[User] = [] # Forward reference works
234+
class User(BaseModel):
235+
name: str
236+
age: int
237+
friends: list[User] = [] # Forward reference works
239238

240-
# This works in Pydantic v2
241-
user = User(name="Alice", age=30, friends=[])
242-
```
239+
# This works in Pydantic v2
240+
user = User(name="Alice", age=30, friends=[])
241+
```
243242

244243
!!! warning "from \_\_future\_\_ import annotation is not fully compatible with Pydantic"
245244
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.
@@ -250,57 +249,57 @@ Now mypy will infer the correct type of the result when we call new_user() with
250249

251250
[From MyPy](https://mypy.readthedocs.io/en/stable/runtime_troubles.html#import-cycles): If the cycle import is only needed for type annotations:
252251

253-
```python title="File foo.py" hl_lines="3-6"
254-
from typing import TYPE_CHECKING
252+
```python title="File foo.py" hl_lines="3-6"
253+
from typing import TYPE_CHECKING
255254

256-
if TYPE_CHECKING:
257-
import bar
255+
if TYPE_CHECKING:
256+
import bar
258257

259-
def listify(arg: 'bar.BarClass') -> 'list[bar.BarClass]':
260-
return [arg]
261-
```
258+
def listify(arg: 'bar.BarClass') -> 'list[bar.BarClass]':
259+
return [arg]
260+
```
262261

263-
```python title="File bar.py" hl_lines="1"
264-
from foo import listify
262+
```python title="File bar.py" hl_lines="1"
263+
from foo import listify
265264

266-
class BarClass:
267-
def listifyme(self) -> 'list[BarClass]':
268-
return listify(self)
269-
```
265+
class BarClass:
266+
def listifyme(self) -> 'list[BarClass]':
267+
return listify(self)
268+
```
270269

271270
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:
272271

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-
```
272+
```python title="File models/parent.py" linenums="1" hl_lines="1 8 19-21"
273+
from __future__ import annotations # (1)!
274+
from typing import TYPE_CHECKING, List
275+
from sqlalchemy import String, Integer
276+
from sqlalchemy.orm import Mapped, mapped_column, relationship
277+
from database import Base
278+
279+
if TYPE_CHECKING:
280+
from models.child import Child # (2)!
281+
282+
class Parent(Base):
283+
__tablename__ = "parent"
284+
285+
# SQLAlchemy v2 syntax
286+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
287+
name: Mapped[str] = mapped_column(String(50), nullable=False)
288+
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
289+
290+
# One-to-Many (Parent -> Children)
291+
# children: Mapped[List[Child]] = relationship(
292+
# (3)!
293+
children: Mapped[List["Child"]] = relationship( # (4)!
294+
"Child", # (5)!
295+
back_populates="parent",
296+
cascade="all, delete-orphan",
297+
lazy="selectin" # one of sqlalchemy 2 lazy loading strategies
298+
)
299+
300+
def __repr__(self) -> str:
301+
return f"<Parent(id={self.id}, name='{self.name}')>"
302+
```
304303

305304
1. `from __future__ import annotations` (PEP 563) turns every annotation into a string. Should be used with careful.
306305

@@ -312,39 +311,39 @@ SqlAlchemy also uses [string literal](https://mypy.readthedocs.io/en/stable/runt
312311

313312
5. SQLAlchemy uses string literals (e.g., `"Child"`) to reference models, allowing for lazy loading and avoiding circular dependencies.
314313

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-
```
314+
```python title="File models/child.py" linenums="1" hl_lines="1 8 24-25"
315+
from __future__ import annotations
316+
from typing import TYPE_CHECKING, Optional
317+
from sqlalchemy import String, Integer, ForeignKey
318+
from sqlalchemy.orm import Mapped, mapped_column, relationship
319+
from database import Base
320+
321+
if TYPE_CHECKING:
322+
from models.parent import Parent
323+
324+
class Child(Base):
325+
__tablename__ = "child"
326+
327+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
328+
name: Mapped[str] = mapped_column(String(50), nullable=False)
329+
age: Mapped[int] = mapped_column(Integer, nullable=False)
330+
331+
parent_id: Mapped[int] = mapped_column(
332+
Integer,
333+
ForeignKey("parents.id", ondelete="CASCADE"),
334+
nullable=False
335+
)
336+
337+
# Many-to-One (Child -> Parent)
338+
parent: Mapped[Parent] = relationship(
339+
"Parent",
340+
back_populates="children",
341+
lazy="selectin"
342+
)
343+
344+
def __repr__(self) -> str:
345+
return f"<Child(id={self.id}, name='{self.name}', parent_id={self.parent_id})>"
346+
```
348347

349348
## Type hints
350349

0 commit comments

Comments
 (0)