Type hints trong Python và kiểm tra với mypy
1. Tổng quan
Python là ngôn ngữ dynamic typing, nghĩa là bạn không cần khai báo kiểu dữ liệu trước. Điều này linh hoạt nhưng dễ gây lỗi runtime khi dự án lớn dần. Type hints (PEP 484) cho phép bạn ghi chú kiểu dữ liệu mà không làm thay đổi hành vi lúc chạy, còn mypy sẽ đọc các chú thích đó để phát hiện lỗi kiểu ngay khi viết code.
Đọc xong bài này, bạn sẽ biết cách viết type hints cho biến, hàm, class, dùng Optional, Union, TypedDict, generic, rồi tích hợp mypy vào quy trình kiểm tra code.
2. Type hints cơ bản
2.1. Khai báo kiểu cho biến và hàm
def calculate_total(price: float, quantity: int) -> float:
return price * quantity
customer_name: str = "Nguyễn Văn A"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
price: float,quantity: intlà kiểu tham số đầu vào.-> floatlà kiểu giá trị trả về.customer_name: strlà annotation cho biến, giúp IDE gợi ý và mypy kiểm tra.
46.5
Lưu ý: type hints KHÔNG ép kiểu lúc runtime. Nếu bạn truyền
calculate_total("10", 2), Python vẫn chạy bình thường vì interpreter bỏ qua annotation — chỉ công cụ như mypy mới báo lỗi.
2.2. Cài đặt mypy
pip install mypy
Kiểm tra file:
mypy order_service.py
Nếu không có lỗi, mypy in ra:
Success: no issues found in 1 source file
3. Optional và Union cho giá trị có thể None
Nhiều hàm trả về None khi không tìm thấy dữ liệu. Dùng Optional để thể hiện điều đó tường minh.
from typing import Optional
class Customer:
def __init__(self, customer_id: int, email: str) -> None:
self.customer_id = customer_id
self.email = email
def find_customer(customer_id: int) -> Optional[Customer]:
database = {1: Customer(1, "a@example.com")}
return database.get(customer_id)
customer = find_customer(2)
if customer is not None:
print(customer.email)
Optional[Customer]tương đươngUnion[Customer, None].- Nếu bạn gọi
customer.emailmà không kiểm traNonetrước, mypy sẽ báo lỗiItem "None" of "Optional[Customer]" has no attribute "email".
from typing import Union
def format_id(value: Union[int, str]) -> str:
return str(value).zfill(6)
Union[int, str] cho phép tham số là int hoặc str. Từ Python 3.10 trở đi, bạn có thể viết ngắn gọn hơn bằng int | str thay cho Union[int, str], và Customer | None thay cho Optional[Customer].
4. Kiểu collection: list, dict, tuple
from typing import Dict, List, Tuple
def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
return sum(order_totals), len(order_totals)
def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
return {name: price * 1.1 for name, price in items.items()}
List[float]nghĩa là danh sách các số thực.Tuple[float, int]nghĩa là tuple có đúng 2 phần tử:floatrồiint.Dict[str, float]là dict với key kiểustr, value kiểufloat.
Lưu ý: từ Python 3.9, bạn có thể dùng trực tiếp
list[float],dict[str, float]mà không cần import từtyping. Dùngtyping.List/typing.Dictchủ yếu để tương thích Python 3.8 trở xuống.
5. TypedDict cho dữ liệu dạng dict có cấu trúc
Khi bạn làm việc với JSON hoặc dict có schema cố định, TypedDict giúp mypy kiểm tra từng key.
from typing import TypedDict
class OrderPayload(TypedDict):
order_id: int
customer_email: str
total: float
def create_order(payload: OrderPayload) -> str:
return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"
payload: OrderPayload = {
"order_id": 101,
"customer_email": "b@example.com",
"total": 250.0,
}
print(create_order(payload))
Nếu bạn thiếu key total hoặc gõ nhầm "totl", mypy báo:
error: Missing key "total" for TypedDict "OrderPayload"
Đây là lợi ích lớn so với dùng Dict[str, Any] — bạn kiểm soát được cấu trúc dữ liệu mà vẫn giữ nguyên dict thuần Python lúc runtime.
6. Generic với TypeVar
Khi viết hàm hoặc class dùng chung cho nhiều kiểu dữ liệu, bạn dùng TypeVar để giữ mối liên hệ giữa kiểu đầu vào và đầu ra.
from typing import TypeVar, List
T = TypeVar("T")
def first_item(items: List[T]) -> T:
return items[0]
first_name: str = first_item(["Hùng", "Lan"])
first_number: int = first_item([10, 20, 30])
TypeVar("T")tạo một placeholder kiểu.- Khi gọi
first_item(["Hùng", "Lan"]), mypy suy raT = str, nên kết quả trả về làstr. - Khi gọi với
List[int],T = int. Nhờ vậy mypy biết chính xác kiểu trả về theo từng lần gọi, thay vì chung chung làAny.
7. Class và Protocol thay cho interface
Python không có interface như Java, nhưng Protocol (PEP 544) cho phép bạn định nghĩa structural typing — kiểm tra “có đúng method hay không” thay vì phải kế thừa.
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, amount: float) -> bool:
...
class VnPayGateway:
def charge(self, amount: float) -> bool:
print(f"Charging {amount} via VNPay")
return True
def process_payment(gateway: PaymentGateway, amount: float) -> bool:
return gateway.charge(amount)
process_payment(VnPayGateway(), 199000.0)
VnPayGateway không cần kế thừa PaymentGateway — chỉ cần có method charge(self, amount: float) -> bool là mypy chấp nhận nó thỏa mãn Protocol. Đây là cách làm rất “Pythonic”, giữ duck typing nhưng vẫn có static check.
8. Cấu hình mypy cho dự án
Thêm dependency vào requirements-dev.txt:
mypy==1.11.2
Tạo file cấu hình mypy.ini ở thư mục gốc:
[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
disallow_untyped_defs = Truebắt buộc mọi hàm phải có type hints, tránh bỏ sót.warn_return_any = Truecảnh báo khi hàm trả vềAnymột cách âm thầm.ignore_missing_imports = Truehữu ích khi dùng thư viện bên thứ ba chưa có type stub.
Chạy kiểm tra toàn bộ dự án:
mypy src/
9. Viết test cho hành vi có type hints
Type hints không thay thế test, nhưng giúp test rõ ràng hơn về kiểu dữ liệu đầu vào/đầu ra. Dùng pytest:
<!-- requirements-dev.txt dạng tương đương, chỉ để minh hoạ cấu trúc dependency -->
pytest==8.3.2
from order_service import calculate_total
def test_calculate_total_returns_correct_sum() -> None:
result = calculate_total(price=20.0, quantity=5)
assert result == 100.0
def test_calculate_total_with_zero_quantity() -> None:
result = calculate_total(price=50.0, quantity=0)
assert result == 0.0
Chạy test:
pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED
Bạn có thể kết hợp cả hai bước trong CI: chạy mypy trước để bắt lỗi kiểu, sau đó chạy pytest để kiểm tra logic nghiệp vụ. Hai công cụ bổ sung cho nhau — mypy bắt lỗi “sai kiểu dữ liệu”, pytest bắt lỗi “sai logic”.
10. Lỗi thường gặp và khi nào không cần mypy
- Quên cập nhật type hint khi đổi logic: ví dụ hàm đổi từ trả về
Optional[Customer]sang luôn trả vềCustomer, nhưng bạn quên sửa annotation — mypy sẽ không báo lỗi sai nếu code vẫn khớp kiểu cũ rộng hơn, dẫn đến thông tin annotation không còn chính xác. - Dùng
Anytràn lan: khai báodef process(data: Any) -> Anykhiến mypy mất khả năng kiểm tra, coi như vô hiệu hóa static typing ở chỗ đó. - Annotation sai với thư viện third-party chưa có stub: nhiều package cũ chưa publish type stub, mypy sẽ báo
error: Cannot find implementation or library stub. Bạn xử lý bằngignore_missing_importshoặc càitypes-<package>nếu có. - Khi KHÔNG nên dùng mypy nghiêm ngặt: với script nhỏ, một lần dùng rồi bỏ (throwaway script), hoặc prototype nhanh để thử ý tưởng — ép buộc type hints đầy đủ có thể làm chậm tốc độ viết mà không mang lại giá trị tương xứng.
Lưu ý: mypy chỉ phân tích tĩnh (static analysis) trên source code, không chạy chương trình. Nó không thể bắt được lỗi phụ thuộc vào dữ liệu runtime, ví dụ giá trị đọc từ file cấu hình hay API bên ngoài có đúng kiểu như khai báo hay không.
11. Kết luận
Type hints giúp code Python dễ đọc, dễ bảo trì và giảm lỗi do nhầm kiểu dữ liệu, trong khi mypy biến những chú thích đó thành công cụ kiểm tra thực sự thay vì chỉ là tài liệu. Bạn nên bắt đầu áp dụng dần ở các hàm public, module quan trọng, rồi mở rộng dần ra toàn bộ dự án bằng cấu hình disallow_untyped_defs. Kết hợp mypy trong CI cùng với test bằng pytest là cách thực tế nhất để vừa giữ sự linh hoạt của Python, vừa có độ an toàn gần với ngôn ngữ static typing.