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: int là kiểu tham số đầu vào.
  • -> float là kiểu giá trị trả về.
  • customer_name: str là 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 đương Union[Customer, None].
  • Nếu bạn gọi customer.email mà không kiểm tra None trước, mypy sẽ báo lỗi Item "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ử: float rồi int.
  • Dict[str, float] là dict với key kiểu str, value kiểu float.

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ùng typing.List/typing.Dict chủ 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 ra T = 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 = True bắt buộc mọi hàm phải có type hints, tránh bỏ sót.
  • warn_return_any = True cảnh báo khi hàm trả về Any một cách âm thầm.
  • ignore_missing_imports = True hữ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 Any tràn lan: khai báo def process(data: Any) -> Any khiế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ằng ignore_missing_imports hoặc cài types-<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.

pythontype-hintsmypystatic-typing