Type hints nâng cao: Generic, Protocol, TypedDict, ParamSpec và variance
Type hint không làm Python chạy nhanh hơn, và Python không kiểm tra chúng lúc chạy. Nhưng trong dự án vài chục nghìn dòng, chúng là tài liệu luôn đúng, giúp IDE gợi ý chính xác, và cho phép các công cụ như mypy hay pyright bắt lỗi trước khi code chạy. Các framework hiện đại (FastAPI, Pydantic, SQLAlchemy 2.0, Typer) còn dùng type hint lúc chạy để validate dữ liệu và sinh tài liệu API.
Bài này dành cho khi bạn đã quen với x: int, list[str], str | None và muốn mô tả chính xác những API phức tạp hơn.
Trong bài này, bạn sẽ học:
- Viết hàm và class generic (cú pháp mới từ Python 3.12)
- Variance: vì sao
list[Dog]không được truyền vào chỗ cầnlist[Animal] - Protocol: “duck typing” có kiểm tra kiểu
TypedDict,Literal,@overload,NewType,Final- ParamSpec: viết decorator giữ nguyên chữ ký hàm
Self,TypeIs,assert_nevervà kiểm tra đầy đủ các trường hợpAnnotatedvà cách FastAPI/Pydantic dùng type hint lúc chạy
Mọi lỗi kiểu trong bài được kiểm chứng bằng mypy (chạy uvx mypy file.py hoặc pip install mypy). Pyright (dùng trong VS Code/Pylance) cho kết quả tương tự.
Generic: một hàm, nhiều kiểu
Phần tiêu đề “Generic: một hàm, nhiều kiểu”Hàm trả về phần tử đầu tiên của một dãy. Viết thế nào cho đúng kiểu?
from typing import Any
def first(items: list[Any]) -> Any: return items[0]
x = first([1, 2, 3]) # x có kiểu Any -> mất hết thông tin, IDE không gợi ý được gìTa cần nói: “nếu vào là list các T thì ra là T”. T là một biến kiểu (type variable). Từ Python 3.12 (PEP 695):
from collections.abc import Sequence
def first[T](items: Sequence[T]) -> T: return items[0]
reveal_type(first([1, 2])) # mypy: Revealed type is "int"reveal_type(first(["a", "b"])) # mypy: Revealed type is "str"(reveal_type là “hàm” đặc biệt mà type checker hiểu; lúc chạy, từ 3.11 có typing.reveal_type.)
Trước 3.12, bạn phải khai báo TypeVar riêng - bạn sẽ còn gặp cách này trong rất nhiều code:
from typing import TypeVarfrom collections.abc import Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T: return items[0]Giới hạn biến kiểu
Phần tiêu đề “Giới hạn biến kiểu”from collections.abc import Sized
# T phải là kiểu con của một kiểu (bound)def longest[T: Sized](a: T, b: T) -> T: return a if len(a) >= len(b) else b
# T phải là MỘT TRONG các kiểu cho trước (constraints)def biggest[T: (int, float)](a: T, b: T) -> T: return a if a > b else b
reveal_type(longest([1, 2], [3])) # list[int] - giữ nguyên kiểu cụ thể, không phải SizedClass generic
Phần tiêu đề “Class generic”class Stack[T]: def __init__(self) -> None: self._items: list[T] = []
def push(self, item: T) -> None: self._items.append(item)
def pop(self) -> T: return self._items.pop()
s = Stack[int]()s.push(1)s.push("hai") # mypy: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int"Cách cũ: class Stack(Generic[T]): ....
Type alias với từ khoá type (3.12+)
Phần tiêu đề “Type alias với từ khoá type (3.12+)”type Json = dict[str, Json] | list[Json] | str | int | float | bool | Nonetype Pair[T] = tuple[T, T]
def load(raw: str) -> Json: ...def swap[T](p: Pair[T]) -> Pair[T]: ...Alias có thể tự tham chiếu (như Json) và có tham số kiểu.
Variance: vì sao list[Dog] không phải list[Animal]?
Phần tiêu đề “Variance: vì sao list[Dog] không phải list[Animal]?”Đây là khái niệm khiến nhiều người bối rối nhất khi dùng type checker:
from collections.abc import Sequence
class Animal: ...class Dog(Animal): ...
def feed_all(animals: list[Animal]) -> None: ...
def show_all(animals: Sequence[Animal]) -> None: ...
dogs: list[Dog] = [Dog()]feed_all(dogs) # ❌ mypy: incompatible type "list[Dog]"; expected "list[Animal]" # note: "list" is invariant ... Consider using "Sequence" instead, which is covariantshow_all(dogs) # ✅ OKDog là Animal, vậy tại sao list[Dog] không phải list[Animal]? Hãy xem feed_all có thể làm gì:
def feed_all(animals: list[Animal]) -> None: animals.append(Animal()) # hoàn toàn hợp lệ với list[Animal]Nếu cho phép truyền dogs vào, list dogs - vốn được hứa chỉ chứa Dog - giờ có một Animal không phải chó. Code phía sau gọi dogs[-1].bark() sẽ lỗi.
listlà invariant (bất biến kiểu): vì nó cho phép ghi,list[Dog]vàlist[Animal]không thay thế được cho nhau.Sequencelà covariant (hiệp biến): vì nó chỉ đọc, một dãy chó chắc chắn là một dãy động vật.
Quy tắc thực hành: tham số của hàm nên dùng kiểu trừu tượng, chỉ đọc nhất có thể - Sequence, Iterable, Mapping thay vì list, dict. Hàm của bạn vừa linh hoạt hơn (nhận cả tuple, list…), vừa tránh được lỗi variance. Kiểu trả về thì nên cụ thể (list[int]).
| Kiểu | Variance | Vì sao |
|---|---|---|
list[T], dict[K, V], set[T] |
invariant | có thao tác ghi |
Sequence[T], Iterable[T], frozenset[T], tuple[T, ...] |
covariant | chỉ đọc |
Mapping[K, V] |
invariant với K, covariant với V | |
Callable[[Arg], Ret] |
contravariant với Arg, covariant với Ret | hàm nhận Animal dùng được ở chỗ cần hàm nhận Dog, không ngược lại |
Với cú pháp mới của 3.12, type checker tự suy ra variance cho class generic của bạn dựa trên cách T được dùng.
Protocol: duck typing có kiểm tra
Phần tiêu đề “Protocol: duck typing có kiểm tra”Python theo triết lý “duck typing”: không quan tâm object thuộc class nào, chỉ cần nó có phương thức cần thiết. Protocol (PEP 544) cho phép mô tả điều đó cho type checker - gọi là structural typing:
from typing import Protocol
class SupportsClose(Protocol): def close(self) -> None: ...
class FileLike: def close(self) -> None: print("đóng file")
class Connection: def close(self) -> None: print("đóng kết nối")
def shutdown(resource: SupportsClose) -> None: resource.close()
shutdown(FileLike()) # ✅ không cần kế thừa SupportsCloseshutdown(Connection()) # ✅shutdown(42) # ❌ mypy: incompatible type "int"; expected "SupportsClose"So với ABC (nominal typing), class không cần biết tới protocol, không cần kế thừa. Rất hợp khi:
- Nhận object từ thư viện bên thứ ba mà bạn không sửa được.
- Viết hàm chỉ cần một phần nhỏ hành vi của object (dependency injection, dễ mock khi test).
Muốn dùng isinstance với protocol lúc chạy, thêm @runtime_checkable - nhưng nó chỉ kiểm tra sự tồn tại của phương thức, không kiểm tra chữ ký.
Thư viện chuẩn có sẵn nhiều protocol: Iterable, Sized, Hashable, SupportsInt, SupportsAbs…
TypedDict: dict có cấu trúc
Phần tiêu đề “TypedDict: dict có cấu trúc”Dữ liệu JSON thường là dict với các key cố định. dict[str, Any] không nói lên được gì:
from typing import TypedDict, NotRequired
class Movie(TypedDict): title: str year: int rating: NotRequired[float] # key có thể vắng mặt
m: Movie = {"title": "Mắt biếc", "year": "2019"}# lỗi mypy: Incompatible types (expression has type "str", TypedDict item "year" has type "int")
def describe(movie: Movie) -> str: return f"{movie['title']} ({movie['year']})"Lúc chạy, Movie(...) vẫn chỉ là một dict thường - không tốn thêm chi phí. Khi cần validate thật lúc chạy (dữ liệu từ API), dùng Pydantic hoặc dataclass.
Python 3.13 thêm ReadOnly[...] cho các key không được sửa; total=False cho phép mọi key đều tuỳ chọn.
Literal: chỉ chấp nhận vài giá trị cụ thể
Phần tiêu đề “Literal: chỉ chấp nhận vài giá trị cụ thể”from typing import Literal
type Mode = Literal["r", "w", "a"]
def open_file(path: str, mode: Mode) -> None: ...
open_file("a.txt", "r") # ✅open_file("a.txt", "x") # ❌ mypy: expected "Literal['r', 'w', 'a']"Lỗi gõ nhầm chuỗi ("recieved" thay vì "received") bị bắt ngay trong IDE.
Kiểm tra đầy đủ với assert_never
Phần tiêu đề “Kiểm tra đầy đủ với assert_never”from typing import Literal, assert_never
type Shape = Literal["circle", "square", "triangle"]
def corners(shape: Shape) -> int: if shape == "circle": return 0 elif shape == "square": return 4 else: assert_never(shape) # mypy: Argument 1 to "assert_never" has incompatible type "Literal['triangle']"; expected "Never"Sau hai nhánh if, type checker biết shape chỉ còn có thể là "triangle". assert_never yêu cầu kiểu Never (không còn khả năng nào), nên type checker báo lỗi và chỉ ra chính xác trường hợp bị quên. Khi bạn thêm một giá trị mới vào Shape, mọi chỗ xử lý chưa đầy đủ sẽ lập tức bị báo. Kỹ thuật này dùng được với Enum và match/case.
@overload: kiểu trả về phụ thuộc kiểu đầu vào
Phần tiêu đề “@overload: kiểu trả về phụ thuộc kiểu đầu vào”from typing import overload
@overloaddef parse(data: str) -> int: ...@overloaddef parse(data: bytes) -> str: ...
def parse(data: str | bytes) -> int | str: # cài đặt thật return int(data) if isinstance(data, str) else data.decode()
reveal_type(parse("1")) # intreveal_type(parse(b"1")) # strKhông có overload, kiểu trả về luôn là int | str và người gọi phải tự kiểm tra lại. Các hàm thư viện chuẩn như open() dùng overload để trả về TextIOWrapper hay BufferedReader tuỳ theo mode.
ParamSpec: decorator giữ nguyên chữ ký
Phần tiêu đề “ParamSpec: decorator giữ nguyên chữ ký”Một decorator viết thông thường làm mất thông tin kiểu của hàm được bọc:
from collections.abc import Callablefrom typing import Any
def logged(func: Callable[..., Any]) -> Callable[..., Any]: def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) return wrapperSau khi bọc, add(1, "2") không bị báo lỗi, kiểu trả về thành Any. ParamSpec (PEP 612) “bắt” toàn bộ danh sách tham số:
import functoolsfrom collections.abc import Callable
def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]: @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f"gọi {func.__name__}") return func(*args, **kwargs) return wrapper
@loggeddef add(a: int, b: int) -> int: return a + b
add(1, 2) # ✅ trả về intadd(1, "2") # ❌ mypy: Argument 2 to "add" has incompatible type "str"; expected "int"[**P, R] là cú pháp 3.12; trước đó dùng P = ParamSpec("P"), R = TypeVar("R"). Khi decorator thêm tham số vào đầu hàm (ví dụ tự truyền kết nối database), dùng Concatenate[Connection, P].
Các công cụ nhỏ mà hữu ích
Phần tiêu đề “Các công cụ nhỏ mà hữu ích”Self (3.11+) - trả về chính kiểu của class
Phần tiêu đề “Self (3.11+) - trả về chính kiểu của class”from typing import Self
class QueryBuilder: def where(self, cond: str) -> Self: ... return self
class UserQuery(QueryBuilder): def active(self) -> Self: return self
UserQuery().where("age > 18").active() # ✅ where() trả về UserQuery, không phải QueryBuilderNewType - phân biệt các giá trị cùng kiểu gốc
Phần tiêu đề “NewType - phân biệt các giá trị cùng kiểu gốc”from typing import NewType
UserId = NewType("UserId", int)OrderId = NewType("OrderId", int)
def get_user(uid: UserId) -> None: ...
get_user(UserId(5)) # ✅get_user(5) # ❌ mypy: incompatible type "int"; expected "UserId"get_user(OrderId(5)) # ❌ nhầm ID đơn hàng thành ID người dùngLúc chạy, UserId(5) trả về đúng số 5 - không tốn chi phí.
Final và ClassVar
Phần tiêu đề “Final và ClassVar”from typing import Final, ClassVar
MAX_RETRY: Final = 3MAX_RETRY = 4 # ❌ mypy: Cannot assign to final name "MAX_RETRY"
class Counter: instances: ClassVar[int] = 0 # biến của class, không phải của instance count: int # biến của instanceTypeIs (3.13+) - hàm kiểm tra kiểu tự viết
Phần tiêu đề “TypeIs (3.13+) - hàm kiểm tra kiểu tự viết”from typing import TypeIs
def is_str(value: object) -> TypeIs[str]: return isinstance(value, str)
def handle(value: int | str) -> None: if is_str(value): reveal_type(value) # str else: reveal_type(value) # int - thu hẹp cả nhánh elseTypeGuard (3.10) là phiên bản cũ hơn, chỉ thu hẹp nhánh if. Lưu ý: kiểu trong TypeIs[...] phải là kiểu con của kiểu tham số - mypy sẽ báo lỗi nếu bạn viết def f(v: list[object]) -> TypeIs[list[str]] vì list là invariant.
Type hint lúc chạy: Annotated
Phần tiêu đề “Type hint lúc chạy: Annotated”Annotated[T, metadata] gắn thêm thông tin vào một kiểu. Type checker chỉ thấy T, còn thư viện đọc được metadata lúc chạy:
from typing import Annotated, get_type_hints
type Age = Annotated[int, "phải từ 0 đến 150"]
def register(name: str, age: Annotated[int, "0..150"]) -> None: ...
print(get_type_hints(register, include_extras=True))# {'name': <class 'str'>, 'age': typing.Annotated[int, '0..150'], 'return': <class 'NoneType'>}Đây chính là cách FastAPI và Pydantic hoạt động:
# FastAPI (tham khảo)from typing import Annotatedfrom fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items")def list_items(limit: Annotated[int, Query(ge=1, le=100)] = 10): ...FastAPI đọc annotation lúc chạy để biết limit là số nguyên từ 1 đến 100, tự validate request, và sinh tài liệu OpenAPI.
Áp dụng type hint vào dự án thực tế
Phần tiêu đề “Áp dụng type hint vào dự án thực tế”- Bắt đầu với chữ ký hàm công khai (tham số và giá trị trả về); biến cục bộ thường được suy luận tự động.
- Bật kiểm tra dần dần: mypy với
--strictcho code mới, cấu hình theo từng module cho code cũ. - Đừng lạm dụng
Any- mỗiAnylà một lỗ hổng mà type checker không nhìn qua được. Dùngobjectkhi thực sự “nhận mọi thứ”. - Tham số: kiểu trừu tượng (
Iterable,Mapping, Protocol). Giá trị trả về: kiểu cụ thể. - Chạy type checker trong CI cùng với test.
Bài tập
Phần tiêu đề “Bài tập”- Viết class generic
Cache[K, V]vớiget(key: K) -> V | Nonevàset(key: K, value: V) -> None. Kiểm tra bằng mypy rằngCache[str, int]().set("a", "b")bị báo lỗi. - Viết decorator
retry(times: int)(decorator có tham số) giữ nguyên chữ ký hàm bằng ParamSpec. - Định nghĩa Protocol
Repository[T]cóget(id: int) -> T | Nonevàadd(item: T) -> None, viết hai cài đặt (InMemoryRepository,SqliteRepository) không kế thừa Protocol, và một hàm service nhậnRepository[User].
Kết luận
Phần tiêu đề “Kết luận”- Generic (
def f[T],class C[T]) giữ thông tin kiểu xuyên suốt hàm và class. listlà invariant; dùngSequence/Iterable/Mappingcho tham số để linh hoạt và đúng kiểu.Protocolmô tả duck typing - không cần kế thừa.TypedDict,Literal,NewType,Finalmô tả dữ liệu chính xác hơn mà không tốn chi phí lúc chạy.ParamSpecgiúp decorator không làm mất chữ ký;@overloadmô tả kiểu trả về phụ thuộc đầu vào.assert_neverbiến việc quên xử lý một trường hợp thành lỗi lúc kiểm tra kiểu.Annotatedlà cầu nối giữa type hint và các framework như FastAPI, Pydantic.
Bài tiếp theo: functools, itertools và operator.