Skip to content

Commit 576e358

Browse files
committed
python typing / add protocol and typing narrowing
1 parent 240ac9a commit 576e358

File tree

2 files changed

+136
-7
lines changed

2 files changed

+136
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,4 @@ cython_debug/
161161

162162
.vscode/
163163
site/
164+
_local/

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

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ def f() -> AliasType:
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+
In the below example, we define a type variable `U` that is bound to the `User` parent class.
180+
This allows us to create a function that can return an instance of any subclass of `User`, while still providing type safety. See the [fastapi-demo for concrete example.](https://github.com/copdips/fastapi-demo/blob/d9922c99404f5d6406e2f10b02822d19a6bc3b91/app/services/base.py#L13-L33)
181+
179182
```python title="Python 3.12 syntax"
180183
def new_user[U: User](user_class: type[U]) -> U:
181184
# Same implementation as before
@@ -345,7 +348,132 @@ class Child(Base):
345348
return f"<Child(id={self.id}, name='{self.name}', parent_id={self.parent_id})>"
346349
```
347350

348-
## Type hints
351+
## Callable and Protocol
352+
353+
[From MyPy](https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols): We can use [Protocols](https://typing.python.org/en/latest/spec/protocol.html) to define [callable](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable) types with a special [**call**](https://docs.python.org/3/reference/datamodel.html#object.__call__) member:
354+
355+
Callback `protocols` and `Callable` types can be used mostly interchangeably, but protocols are more flexible and can be used to define more complex callable types.
356+
357+
```python title="Callable Protocol"
358+
from collections.abc import Iterable
359+
from typing import Optional, Protocol
360+
361+
class Combiner(Protocol):
362+
def __call__(self, *vals: bytes, maxlen: int | None = None) -> list[bytes]: ...
363+
364+
def batch_proc(data: Iterable[bytes], cb_results: Combiner) -> bytes:
365+
for item in data:
366+
...
367+
368+
def good_cb(*vals: bytes, maxlen: int | None = None) -> list[bytes]:
369+
...
370+
def bad_cb(*vals: bytes, maxitems: int | None) -> list[bytes]:
371+
...
372+
373+
batch_proc([], good_cb) # OK
374+
batch_proc([], bad_cb) # Error! Argument 2 has incompatible type because of
375+
# different name and kind in the callback
376+
```
377+
378+
!!! warning "Protocol doesn't like isinstance()"
379+
Although the `@runtime_checkable` decorator allows using `isinstance()` to check if an object conforms to a Protocol, this approach [has limitations and performance issues](https://mypy.readthedocs.io/en/stable/protocols.html#using-isinstance-with-protocols). Therefore, it's recommended to use `Protocol` exclusively for static type checking and avoid runtime `isinstance()` checks, at least until Python 3.13.
380+
381+
```python
382+
from typing import Protocol, runtime_checkable
383+
384+
@runtime_checkable
385+
class Drawable(Protocol):
386+
def draw(self) -> None: ...
387+
388+
class Circle:
389+
def draw(self) -> None:
390+
print("Drawing a circle")
391+
392+
# This works but is not recommended
393+
circle = Circle()
394+
if isinstance(circle, Drawable): # Avoid this pattern
395+
circle.draw()
396+
397+
# Preferred approach: rely on duck typing
398+
def render(obj: Drawable) -> None:
399+
obj.draw() # Type checker ensures obj has draw() method
400+
401+
render(circle) # Type-safe without runtime checks
402+
```
403+
404+
## Type narrowing for parameters in multi-type
405+
406+
We know how to define parameters with union types `a: int | str`, but how can we help static type checkers understand which specific type a parameter has within if-else control flow?
407+
408+
Previously, we can simply use `isinstance()` function, Python 3.13 introduced `typing.TypeIs` ([PEP 742](https://peps.python.org/pep-0742/))for this purpose (use [typing_extensions.TypeIs](https://typing-extensions.readthedocs.io/en/latest/index.html#typing_extensions.TypeIs) for Python versions prior to 3.13).
409+
410+
```python title="use isinstance() for type narrowing"
411+
# https://mypy.readthedocs.io/en/stable/type_narrowing.html#type-narrowing-expressions
412+
from typing import reveal_type
413+
414+
415+
def function(arg: object):
416+
if isinstance(arg, int):
417+
# Type is narrowed within the ``if`` branch only
418+
reveal_type(arg) # Revealed type: "builtins.int"
419+
elif isinstance(arg, str) or isinstance(arg, bool):
420+
# Type is narrowed differently within this ``elif`` branch:
421+
reveal_type(arg) # Revealed type: "builtins.str | builtins.bool"
422+
423+
# Subsequent narrowing operations will narrow the type further
424+
if isinstance(arg, bool):
425+
reveal_type(arg) # Revealed type: "builtins.bool"
426+
427+
# Back outside of the ``if`` statement, the type isn't narrowed:
428+
reveal_type(arg) # Revealed type: "builtins.object"
429+
```
430+
431+
```python title="use TypeIs with Python 3.13 new syntax"
432+
# https://mypy.readthedocs.io/en/stable/type_narrowing.html#type-narrowing-expressions
433+
from typing import TypeIs, reveal_type
434+
435+
def is_str(x: object) -> TypeIs[str]:
436+
return isinstance(x, str)
437+
438+
def process(x: int | str) -> None:
439+
if is_str(x):
440+
reveal_type(x) # Revealed type is 'str'
441+
print(x.upper()) # Valid: x is str
442+
else:
443+
reveal_type(x) # Revealed type is 'int'
444+
print(x + 1) # Valid: x is int
445+
446+
In [6]: process(2)
447+
Runtime type is 'int'
448+
3
449+
450+
In [7]: process("2")
451+
Runtime type is 'str'
452+
```
453+
454+
!!! note "Don't use TypeGuard, it works only in if branch, not else branch. TypeIs works for both if and else branch."
455+
456+
### When to use TypeIs over isinstance()
457+
458+
[PEP 724 says](https://peps.python.org/pep-0742/#when-to-use-typeis): Python code often uses functions like `isinstance()` to distinguish between different possible types of a value. Type checkers understand `isinstance()` and various other checks and use them to narrow the type of a variable. However, sometimes you want to reuse a more complicated check in multiple places, or you use a check that the type checker doesn't understand. In these cases, you can define a `TypeIs` function to perform the check and allow type checkers to use it to narrow the type of a variable.
459+
460+
A TypeIs function takes a single argument and is annotated as returning `TypeIs[T]`, where `T` is the type that you want to narrow to. The function must return `True` if the argument is of type `T`, and `False` otherwise. The function can then be used in if checks, just like you would use `isinstance()`. For example:
461+
462+
```python
463+
# https://peps.python.org/pep-0742/#when-to-use-typeis
464+
rom typing import TypeIs, Literal
465+
466+
type Direction = Literal["N", "E", "S", "W"]
467+
468+
def is_direction(x: str) -> TypeIs[Direction]:
469+
return x in {"N", "E", "S", "W"}
470+
471+
def maybe_direction(x: str) -> None:
472+
if is_direction(x):
473+
print(f"{x} is a cardinal direction")
474+
else:
475+
print(f"{x} is not a cardinal direction")
476+
```
349477

350478
## Typing tools
351479

@@ -362,24 +490,24 @@ Ref. Pyright in [this post](../2021/2021-01-04-python-lint-and-format.md#pyright
362490
### RightTyper
363491

364492
During an internal tech demo at my working, I heard about [RightTyper](https://github.com/RightTyper/RightTyper), a Python tool that generates type annotations for function arguments and return values.
365-
Its important to note that RightTyper doesnt statically parse your Python files to add types; instead, it needs to run your code to detect types on the fly. So, one of the best ways to use RightTyper is with python `-m pytest`, assuming you have good test coverage.
493+
It's important to note that **RightTyper** doesn't statically parse your Python files to add types; instead, it needs to run your code to detect types on the fly. So, one of the best ways to use **RightTyper** is with python `-m pytest`, assuming you have good test coverage.
366494

367495
### ty
368496

369-
[ty](https://github.com/astral-sh/ty) represents the next generation of Python type checking tools. Developed by the team behind the popular [ruff](https://docs.astral.sh/ruff/) linter, ty is implemented in Rust for exceptional performance.
497+
[ty](https://github.com/astral-sh/ty) represents the next generation of Python type checking tools. Developed by the team behind the popular [ruff](https://docs.astral.sh/ruff/) linter, **ty** is implemented in Rust for exceptional performance.
370498
It functions both as a type checker and language server, offering seamless integration through its dedicated [VSCode extension ty-vscode](https://github.com/astral-sh/ty-vscode).
371499

372-
While Ruff excels at various aspects of Python linting, type checking remains outside its scope.
500+
While **Ruff** excels at various aspects of Python linting, type checking remains outside its scope.
373501
ty aims to fill this gap, though it's currently in preview and still evolving toward production readiness.
374-
The combination of Ruff and ty promises to provide a comprehensive Python code quality toolkit.
502+
The combination of **Ruff** and **ty** promises to provide a comprehensive Python code quality toolkit.
375503

376504
### pyrefly
377505

378506
[pyrefly](https://pyrefly.org/) emerges as another promising entrant in the Python type checking landscape.
379-
Developed by Meta and also written in Rust, pyrefly offers both type checking capabilities and language server functionality.
507+
Developed by Meta and also written in Rust, **pyrefly** offers both type checking capabilities and language server functionality.
380508
While still in preview, it demonstrates the growing trend of high-performance Python tooling implemented in Rust.
381509

382510
The tool integrates smoothly with modern development environments through its [VSCode extension refly-vscode](https://marketplace.visualstudio.com/items?itemName=meta.pyrefly), making it accessible to a wide range of developers.
383511
Its backing by Meta suggests potential for robust development and long-term support.
384512

385-
Just a quick test, pyrefly seems to generate more typing errors than ty.
513+
Just a quick test, **pyrefly** seems to generate more typing errors than **ty**.

0 commit comments

Comments
 (0)