Bỏ qua để đến nội dung

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ần list[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_never và kiểm tra đầy đủ các trường hợp
  • Annotated và 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ự.

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 TypeVar
from collections.abc import Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
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 Sized
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 Json = dict[str, Json] | list[Json] | str | int | float | bool | None
type 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 covariant
show_all(dogs) # ✅ OK

DogAnimal, 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.

  • list là invariant (bất biến kiểu): vì nó cho phép ghi, list[Dog]list[Animal] không thay thế được cho nhau.
  • Sequence là 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.

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 SupportsClose
shutdown(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

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.

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 Enummatch/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
@overload
def parse(data: str) -> int: ...
@overload
def 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")) # int
reveal_type(parse(b"1")) # str

Khô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.

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 Callable
from typing import Any
def logged(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper

Sau 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 functools
from 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
@logged
def add(a: int, b: int) -> int:
return a + b
add(1, 2) # ✅ trả về int
add(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].

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 QueryBuilder

NewType - 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ùng

Lúc chạy, UserId(5) trả về đúng số 5 - không tốn chi phí.

from typing import Final, ClassVar
MAX_RETRY: Final = 3
MAX_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 instance

TypeIs (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 else

TypeGuard (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]]list là invariant.

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 Annotated
from 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.

  • 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 --strict cho code mới, cấu hình theo từng module cho code cũ.
  • Đừng lạm dụng Any - mỗi Any là một lỗ hổng mà type checker không nhìn qua được. Dùng object khi 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.
  1. Viết class generic Cache[K, V] với get(key: K) -> V | Noneset(key: K, value: V) -> None. Kiểm tra bằng mypy rằng Cache[str, int]().set("a", "b") bị báo lỗi.
  2. Viết decorator retry(times: int) (decorator có tham số) giữ nguyên chữ ký hàm bằng ParamSpec.
  3. Định nghĩa Protocol Repository[T]get(id: int) -> T | Noneadd(item: T) -> None, viết hai cài đặt (InMemoryRepository, SqliteRepository) không kế thừa Protocol, và một hàm service nhận Repository[User].
  • Generic (def f[T], class C[T]) giữ thông tin kiểu xuyên suốt hàm và class.
  • list là invariant; dùng Sequence/Iterable/Mapping cho tham số để linh hoạt và đúng kiểu.
  • Protocol mô tả duck typing - không cần kế thừa.
  • TypedDict, Literal, NewType, Final mô tả dữ liệu chính xác hơn mà không tốn chi phí lúc chạy.
  • ParamSpec giúp decorator không làm mất chữ ký; @overload mô tả kiểu trả về phụ thuộc đầu vào.
  • assert_never biến việc quên xử lý một trường hợp thành lỗi lúc kiểm tra kiểu.
  • Annotated là cầu nối giữa type hint và các framework như FastAPI, Pydantic.

Bài tiếp theo: functools, itertools và operator.