mypy 怎么用好?渐进式类型检查该怎么落地?
简化版
Python 的类型注解在运行时基本不做任何事(除了被 Pydantic、FastAPI、dataclass 这类库主动读取),它的价值要靠静态检查器兑现——mypy 是最主流的,pyright(VS Code 的 Pylance 内核)速度更快、对新语法支持更早,ty 和 pyrefly 是新兴的 Rust 实现。mypy 的核心行为要先理解一件事:默认配置下它非常宽松——没有类型注解的函数体根本不会被检查(--check-untyped-defs 才会),任何来自无类型第三方库的值都是 Any,而 Any 会像病毒一样传播(Any 参与的任何运算结果还是 Any,检查全部失效)。所以「加了 mypy 但没发现问题」通常不是代码干净,而是根本没检查到。落地的正确路径是「渐进式」:先在 CI 里跑起来(哪怕零配置),然后逐步收紧——disallow_untyped_defs(要求函数有注解)→ warn_return_any → strict = true;对遗留模块用 [[tool.mypy.overrides]] 单独放宽,新代码严格要求。几个最常撞到的报错:Optional 相关(x: str = None 在新版本会直接报错,要写 str | None)、可变默认参数、Dict[str, Any] 用得太多导致检查失效、以及第三方库没有类型存根(装 types-xxx 或写 .pyi)。类型检查和测试是互补的:类型检查覆盖「所有代码路径的类型正确性」但不验证逻辑,测试验证逻辑但只覆盖你写到的路径——两者结合才完整。核心记忆:注解运行时不生效,靠静态检查兑现;默认很宽松,Any 会传播;渐进式收紧 + 遗留模块单独放宽;类型检查和测试互补。
详细版
mypy 严格度的关键开关:
| 选项 | 作用 | 建议 |
|---|---|---|
disallow_untyped_defs | 函数必须有注解 | 首先开启 |
check_untyped_defs | 检查无注解函数的函数体 | 过渡期用 |
warn_return_any | 返回 Any 时警告 | 开启 |
no_implicit_optional | 禁止 x: str = None | 默认已开 |
warn_unused_ignores | 无用的 # type: ignore 报警 | 开启 |
strict = true | 打开一整组严格选项 | 新项目直接开 |
# ① ★★pyproject.toml 的推荐配置★★
[tool.mypy]
python_version = "3.12"
★strict = true★ # ★新项目直接开★
★warn_unused_ignores = true★ # ★★清理过期的 ignore★★
★warn_redundant_casts = true★
★show_error_codes = true★ # ★★便于精确 ignore★★
★pretty = true★
exclude = ["build/", "migrations/"]
# ★★对遗留模块单独放宽(渐进式的关键)★★
[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
★ignore_errors = true★ # ★暂时完全跳过★
[[tool.mypy.overrides]]
module = ["myapp.old_module"]
★disallow_untyped_defs = false★ # ★只放宽这一项★
# ★★第三方库没有存根★★
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*", "another_lib"]
★ignore_missing_imports = true★
# ② ★★Any 的传播(最重要的认知)★★
import untyped_lib # ★没有类型信息★
def process(data): # ★★没注解 → 整个函数体不检查★★
result = untyped_lib.get() # ★result: Any★
return result.anything.at.all() # ★★不报错!Any 上什么都合法★★
# ✓ 加上注解和边界类型
def process(data: dict[str, str]) -> User:
raw: Any = untyped_lib.get()
★return User.model_validate(raw)★ # ★★在边界处收敛成具体类型★★
# ③ ★现代类型注解语法(3.10+)★
def f(
a: ★int | None★ = None, # ★取代 Optional[int]★
b: ★list[str]★ = [], # ★取代 List[str](3.9+)★
c: ★dict[str, int]★ | None = None,
) -> ★tuple[int, str]★: ...
from collections.abc import ★Sequence, Callable, Iterator★ # ★不用 typing 里的★
# ④ ★★Protocol:结构化子类型(比 ABC 更 Pythonic)★★
from typing import Protocol
class Repo(★Protocol★):
def get(self, id: int) -> User | None: ...
def save(self, u: User) -> None: ...
def service(repo: Repo) -> None: ... # ★★任何有这两个方法的对象都行★★
# ★★不需要显式继承,测试用的 FakeRepo 也自动满足★★
# ⑤ ★类型收窄(narrowing)★
def f(x: int | str | None) -> str:
if x is None:
return "none" # ★这里 x: None★
if ★isinstance(x, int)★:
return str(x) # ★这里 x: int★
return x.upper() # ★★这里 mypy 知道 x: str★★
# ★TypeGuard:自定义收窄(3.10+)★
from typing import TypeGuard
def is_str_list(v: list[object]) -> ★TypeGuard[list[str]]★:
return all(isinstance(x, str) for x in v)
# ⑥ ★★常用的高级类型★★
from typing import TypeVar, Generic, Literal, Final, TypedDict, overload, cast
T = TypeVar("T")
def first(xs: list[T]) -> T | None: ... # ★泛型★
Mode = ★Literal["r", "w", "a"]★ # ★★字面量类型(比 str 精确)★★
MAX: ★Final★ = 100 # 常量
class UserDict(★TypedDict★): # ★★给 dict 加结构★★
id: int
name: str
email: ★NotRequired[str]★ # 3.11+
★@overload★ # ★不同参数不同返回类型★
def get(k: str) -> str: ...
@overload
def get(k: str, default: T) -> str | T: ...
x = ★cast(User, raw)★ # ★★告诉 mypy「相信我」(运行时无效)★★
# ⑦ ★★精确的 ignore★★
result = lib.call() ★# type: ignore[no-any-return]★ # ★带错误码★
# ✗ # type: ignore ★★裸 ignore 会掩盖新问题★★
# ★配 warn_unused_ignores 清理过期的★
⚠️ 三个必须记住的点:① 类型注解在运行时几乎不做任何事。
def f(x: int) -> str:传进去一个字符串不会有任何报错——Python 只是把注解存进__annotations__里,除非有库主动去读它(Pydantic 用它做校验、FastAPI 用它生成文档和解析参数、dataclasses用它生成__init__)。所以注解的价值完全靠静态检查器兑现:不跑 mypy/pyright 的注解,只是一种「有类型检查器验证过的注释」的错觉——它甚至可能是错的(注解写int实际传str,不检查就永远不知道)。这也意味着:类型注解必须进 CI,否则会逐渐腐烂。②Any会像病毒一样传播,是「加了 mypy 却查不出问题」的头号原因。任何来自无类型第三方库的返回值都是Any,而Any参与的任何操作结果还是Any——Any上访问任何属性、调用任何方法、做任何运算,mypy 都认为合法。所以一个Any能让整条调用链的检查全部失效。应对方式是在边界处收敛:从外部(第三方库、JSON、数据库)拿到的数据,立刻用 Pydantic 校验或cast成具体类型,别让Any流进业务逻辑。用--disallow-any-expr之类的严格选项可以强制暴露它们。③ mypy 默认配置极其宽松,最典型的是「没有类型注解的函数体完全不检查」。一个几百行的老函数,只要没写参数和返回值的注解,mypy 就整个跳过它——你会得到「Success: no issues found」的假象。过渡期可以开check_untyped_defs = true(检查无注解函数的函数体),最终目标是disallow_untyped_defs = true(强制所有函数都有注解)。
完整版教学
一、类型注解在运行时做什么
★ ★★运行时:几乎什么都不做★★:
def f(x: int) -> str:
return x # ★★返回 int,运行时完全不报错★★
f("hello") # ★★传字符串,也不报错★★
★ ★Python 只是把注解存起来★:
f.__annotations__ → {'x': int, 'return': str}
★ ★★谁会真的用注解★★:
┌────────────────────────────────────────────────────┐
│ ★dataclasses★ :★用注解生成 __init__ 等★ │
│ ★Pydantic★ :★用注解做运行时校验和转换★ │
│ ★FastAPI★ :★用注解解析参数 + 生成 OpenAPI★ │
│ ★attrs / SQLModel★:类似 │
│ ★typing.get_type_hints()★:手动读取 │
│ ★静态检查器★ :★★mypy / pyright(不在运行时)★★ │
└────────────────────────────────────────────────────┘
★ ★from __future__ import annotations(PEP 563)★:
★ 效果:★所有注解变成字符串,不在定义时求值★
★ ✓ 好处:
- ★可以用还未定义的类型(前向引用)★
- ★import 更快(不用真的构造类型对象)★
- ★可以在 3.9 用 list[str] 这类新语法★
★ ✗ 坑:
- ★★依赖运行时读注解的库可能出问题★★
(Pydantic 需要能解析这些字符串 → 通常没问题但要注意作用域)
- ★get_type_hints() 需要能访问到那些名字★
★ ★TYPE_CHECKING:只为类型检查而 import★:
from typing import TYPE_CHECKING
★if TYPE_CHECKING:★
from myapp.models import User # ★★运行时不 import★★
def f(u: ★"User"★) -> None: ... # ★用字符串(或配 future annotations)★
★ ✓ ★解决循环导入★
★ ✓ ★避免为了类型注解引入重依赖★
★ ★运行时类型校验的方案(如果确实需要)★:
★ ✓ Pydantic:★最主流★
★ ✓ ★beartype / typeguard★:装饰器形式的运行时检查
@beartype
def f(x: int) -> str: ... # ★★运行时真的会检查★★
★ ★但注意:运行时检查有性能开销★
★ ★大多数场景:静态检查 + 边界处用 Pydantic 就够了★
★ ★★注解的另一个价值:文档和 IDE★★:
★ ✓ ★IDE 补全、跳转、重构(改名能找到所有引用)★
★ ✓ ★函数签名即文档★(比 docstring 里写"参数是个字典"可靠)
★ ✓ ★code review 时能看出意图★
★ ★即使不跑 mypy,注解本身也有价值——但价值小得多★
类型注解在运行时几乎什么都不做——def f(x: int) -> str: return x 传字符串、返回 int 都不会报错,Python 只是把注解存进 __annotations__。真正会读它的是几类库:dataclasses(生成 __init__)、Pydantic(运行时校验)、FastAPI(解析参数和生成文档)、以及静态检查器。from __future__ import annotations 让所有注解变成字符串不求值——好处是支持前向引用、import 更快,但依赖运行时读注解的库要注意作用域问题。TYPE_CHECKING 块用于「只为类型检查而 import」——解决循环导入、避免为了注解引入重依赖。确实需要运行时校验时用 Pydantic 或 beartype,但大多数场景「静态检查 + 边界处 Pydantic」就够了。
二、mypy 的默认宽松与收紧
★ ★★默认配置有多宽松(★关键认知★)★★:
# ★零配置跑 mypy★
def process(data): # ★★没注解★★
return data.whatever.chain() # ★★完全不检查★★
# mypy: ★Success: no issues found★ ← ★★假象!★★
★ ★默认行为:★
✗ ★无注解的函数体不检查(--check-untyped-defs 才检查)★
✗ ★无注解的参数默认是 Any★
✗ ★无类型的第三方库 → 所有导入的东西都是 Any★
✗ ★不要求函数必须有注解★
★ ★★strict = true 打开的一整组★★:
disallow_untyped_defs ★函数必须有注解★
disallow_incomplete_defs ★不能只注解一部分参数★
check_untyped_defs ★检查无注解函数的函数体★
disallow_untyped_decorators
★no_implicit_optional★ ★禁止 x: str = None★
★warn_redundant_casts★
★warn_unused_ignores★ ★★无用的 ignore 报警★★
★warn_return_any★ ★返回 Any 时警告★
★disallow_subclassing_any★
★disallow_any_generics★ ★★禁止裸的 list / dict(要写 list[str])★★
strict_equality ★禁止不可能相等的比较★
★ ★★渐进式收紧的路线★★:
★阶段 0:跑起来★
mypy . # ★零配置,先看有多少错★
★[tool.mypy] ignore_errors = true★ # ★全局先关,只让它能跑★
★阶段 1:新代码严格★
[tool.mypy]
★disallow_untyped_defs = true★
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
★ignore_errors = true★ # ★★老代码先豁免★★
★阶段 2:逐模块开启★
# ★每次挑一个模块,去掉它的豁免,修完再合★
module = ["myapp.legacy.a", "myapp.legacy.b"] # ★列表越来越短★
★阶段 3:strict★
★strict = true★
# ★剩余的个别问题用精确的 type: ignore[code]★
★ ★★衡量进度:类型覆盖率★★:
mypy ★--html-report report/★ # ★可视化每个文件的覆盖★
mypy ★--linecount-report .★
# ★或用 mypy 的 --any-exprs-report★
★ ✓ ★把"类型覆盖率"当作和测试覆盖率并列的指标★
★ ★★CI 里的两种策略★★:
① ★全量检查(推荐)★
mypy . # ★配合 overrides 逐步收紧★
② ★只检查改动的文件★
★git diff --name-only origin/main | grep '\.py$' | xargs mypy★
★ ✗ ★问题:改动可能影响其他文件★
★ ✓ 适合超大项目的过渡期
★ ★缓存加速★:
mypy ★--cache-dir=.mypy_cache★ # ★默认就有★
★ ✓ ★CI 里缓存这个目录能大幅提速★
mypy ★--incremental★ # 默认开启
必须理解 mypy 的默认配置有多宽松:无注解的函数体完全不检查、无注解参数是 Any、无类型的第三方库导入的一切都是 Any——所以「Success: no issues found」可能是根本没检查的假象。strict = true 打开一整组选项,其中几个高价值的:disallow_untyped_defs、warn_unused_ignores(清理过期的 ignore)、warn_return_any、disallow_any_generics(禁止裸的 list/dict)。渐进式收紧的路线是四个阶段:先跑起来(全局 ignore_errors)→ 新代码严格 + 老代码豁免 → 逐模块去掉豁免(豁免列表越来越短) → 最终 strict = true。把「类型覆盖率」当作和测试覆盖率并列的指标(mypy --html-report)。CI 里缓存 .mypy_cache 能大幅提速。
三、常见报错与处理
★ ★① Optional 相关(最高频)★:
✗ def f(name: str = None): ...
★error: Incompatible default for argument "name"★
✓ def f(name: ★str | None★ = None): ...
✗ user = get_user() # 返回 User | None
★print(user.name)★
★error: Item "None" of "User | None" has no attribute "name"★
✓ ★收窄★:
if user is None: raise NotFound()
print(user.name) # ★这里 mypy 知道非 None★
✓ ★或断言★:
★assert user is not None★
✓ ★或用 walrus★:
if (user := get_user()) is not None: ...
★ ★② 可变默认参数★:
✗ def f(items: list[str] = ★[]★): ... # ★mypy 不报错但这是 bug★
✓ def f(items: list[str] | None = None):
items = items ★or []★
★ ★③ 第三方库没有类型信息★:
★error: Skipping analyzing "somelib": module is installed,
but missing library stubs or py.typed marker★
✓ ★方案一:装官方存根★
pip install ★types-requests types-PyYAML types-redis★
# ★或 mypy --install-types★
✓ ★方案二:忽略★
[[tool.mypy.overrides]]
module = ["somelib.*"]
★ignore_missing_imports = true★
✓ ★方案三:自己写 .pyi 存根★
# stubs/somelib/__init__.pyi
def do_thing(x: int) -> str: ...
# pyproject: mypy_path = "stubs"
★ ★④ Dict[str, Any] 泛滥(★检查失效的元凶★)★:
✗ def process(data: ★dict[str, Any]★) -> ★dict[str, Any]★:
return {"result": data["key"].something} # ★★全是 Any,不检查★★
✓ ★用 TypedDict★:
class Input(TypedDict):
key: str
count: int
def process(data: Input) -> Output: ...
✓ ★或用 dataclass / Pydantic 模型★
★ ★经验:看到 Dict[str, Any] 就想想能不能给它结构★
★ ★⑤ 装饰器丢失类型★:
✗ def my_decorator(f): # ★没注解 → 被装饰的函数变 Any★
def wrapper(*a, **kw): return f(*a, **kw)
return wrapper
✓ ★用 ParamSpec(3.10+)★:
from typing import ParamSpec, TypeVar, Callable
★P = ParamSpec("P")★; R = TypeVar("R")
def my_decorator(f: ★Callable[P, R]★) -> ★Callable[P, R]★:
@functools.wraps(f)
def wrapper(*a: ★P.args★, **kw: ★P.kwargs★) -> R:
return f(*a, **kw)
return wrapper
★ ★⑥ 需要"逃生舱"时★:
x = ★cast(User, raw_data)★ # ★告诉 mypy「相信我」★
★# type: ignore[error-code]★ # ★★带错误码,精确★★
def f() -> ★Any★: ... # 显式承认无法表达
★ ★三条纪律:★
① ★ignore 必须带错误码★(# type: ignore[arg-type])
② ★开 warn_unused_ignores 清理过期的★
③ ★ignore 旁边写注释说明为什么★
★ ★⑦ 常见错误码速查★:
┌──────────────────────┬────────────────────────────┐
│ ★arg-type★ │ 参数类型不匹配 │
│ ★return-value★ │ 返回值类型不匹配 │
│ ★attr-defined★ │ ★对象没有这个属性★ │
│ ★union-attr★ │ ★★Optional 没收窄★★ │
│ ★assignment★ │ 赋值类型不兼容 │
│ ★no-any-return★ │ ★返回了 Any★ │
│ ★import-untyped★ │ ★第三方库没类型★ │
│ ★call-arg★ │ 参数个数/名字不对 │
│ ★override★ │ ★子类方法签名不兼容★ │
└──────────────────────┴────────────────────────────┘
常见报错里Optional 相关最高频——x: str = None 现在直接报错(要写 str | None)、user.name 在 User | None 上报 union-attr(要先收窄)。dict[str, Any] 泛滥是检查失效的元凶——看到它就想想能不能用 TypedDict 或 dataclass 给它结构。装饰器会丢失类型(被装饰的函数变 Any)——用 ParamSpec 保留签名。第三方库没类型时优先装官方存根(types-requests、mypy --install-types)。需要逃生舱时有三条纪律:ignore 必须带错误码(# type: ignore[arg-type])、开 warn_unused_ignores 清理过期的、旁边写注释说明原因。
四、类型设计的实践
★ ★★Protocol:比 ABC 更适合 Python★★:
# ✗ ABC:要显式继承
class RepoABC(ABC):
@abstractmethod
def get(self, id: int) -> User: ...
class SqlRepo(★RepoABC★): ... # ★必须继承★
# ✓ ★Protocol:结构化子类型★
class Repo(★Protocol★):
def get(self, id: int) -> User | None: ...
class SqlRepo: ... # ★★不用继承,方法对上就行★★
class FakeRepo: ... # ★★测试替身也自动满足★★
def service(repo: ★Repo★): ...
★ ✓ ★不侵入第三方类★
★ ✓ ★测试的 Fake 天然兼容★
★ ✓ ★依赖的是"能力"而不是"血缘"★
# ★运行时也想 isinstance 检查:★
@runtime_checkable
class Repo(Protocol): ...
★ ★★用类型表达业务约束★★:
✗ def transfer(from_: str, to: str, amount: float): ...
★两个 str 参数可以传反、float 有精度问题★
✓ ★NewType 区分语义★:
★AccountId = NewType("AccountId", str)★
★UserId = NewType("UserId", str)★
def transfer(from_: AccountId, to: AccountId, amount: Decimal): ...
# ★★传 UserId 进去会被 mypy 拦住★★
✓ ★Literal 限定取值★:
def open_file(mode: ★Literal["r", "w", "a"]★): ...
open_file("x") # ★★mypy 报错★★
✓ ★Enum★
✓ ★Final 防止意外修改★
★ ★★TypedDict:给 JSON 结构加类型★★:
class Config(TypedDict):
host: str
port: int
★debug: NotRequired[bool]★ # ★3.11+ 可选键★
cfg: Config = json.load(f) # ★★现在有结构了★★
cfg["hostt"] # ★★mypy 报错:拼写错误★★
★ ✓ ★适合"就是字典但有固定结构"的场景★
★ ✗ ★不做运行时校验★(要校验用 Pydantic)
★ ★★泛型与 TypeVar★★:
T = TypeVar("T")
def first(xs: ★Sequence[T]★) -> ★T | None★:
return xs[0] if xs else None
# ★有约束的 TypeVar★
★Num = TypeVar("Num", int, float)★ # ★只能是这两种之一★
★TModel = TypeVar("TModel", bound=BaseModel)★ # ★★必须是 BaseModel 子类★★
# ★泛型类★
class Repository(★Generic[TModel]★):
def get(self, id: int) -> ★TModel | None★: ...
class UserRepo(★Repository[User]★): ... # ★★类型自动传播★★
# ★3.12 的新语法★
★def first[T](xs: list[T]) -> T | None: ...★
★class Repository[TModel]: ...★
★ ★★把类型当作设计工具★★:
★ ✓ ★让非法状态无法表示★:
✗ class Order:
status: str
paid_at: datetime | None # ★status="paid" 但 paid_at=None?★
✓ ★用联合类型表达状态机★:
@dataclass
class PendingOrder: created_at: datetime
@dataclass
class PaidOrder: created_at: datetime; ★paid_at: datetime★
★Order = PendingOrder | PaidOrder★
# ★★"已支付但没有支付时间"这种状态无法构造★★
★ ✓ ★函数签名即契约★:
参数多 = 职责多;返回 Any = 说不清楚
Protocol 比 ABC 更适合 Python——结构化子类型,不需要显式继承,测试的 Fake 天然满足,依赖的是「能力」而不是「血缘」。用类型表达业务约束是高价值的实践:NewType 区分语义(AccountId vs UserId,传反了会被 mypy 拦住)、Literal 限定取值、Enum、Final。TypedDict 给 JSON 结构加类型(拼写错误能被发现,但不做运行时校验)。最高阶的用法是「让非法状态无法表示」——把 status: str + paid_at: datetime | None 改成 PendingOrder | PaidOrder 的联合类型,「已支付但没有支付时间」这种状态就无法构造了。
五、mypy vs 其他检查器
★ ★★四个主要选择★★:
┌────────────┬──────────────────────────────────────────┐
│ ★mypy★ │ ★官方参考实现、生态最成熟★ │
│ │ ✗ ★慢(大项目几十秒到几分钟)★ │
│ │ ✓ 配置最灵活、文档最全 │
│ ★pyright★ │ ★★VS Code Pylance 的内核★★ │
│ │ ✓ ★快很多(TypeScript 写的)★ │
│ │ ✓ ★对新 PEP 支持更早★ │
│ │ ✓ ★类型收窄更聪明★ │
│ │ ✗ 配置和 mypy 不完全兼容 │
│ ★ty★ │ ★Astral(ruff 团队)的 Rust 实现★ │
│ │ ✓ ★极快★ ✗ ★还在早期★ │
│ ★pyrefly★ │ Meta 的 Rust 实现 │
└────────────┴──────────────────────────────────────────┘
★ ★现实:很多团队 IDE 里用 pyright(即时反馈)+ CI 里用 mypy★
★ ✗ ★两者的报错不完全一致 → 可能互相打架★
✓ ★选一个作为"权威"(CI 里跑的那个),另一个只当辅助★
★ ★★类型检查 vs 测试:互补关系★★:
┌──────────────────────────────────────────────────────┐
│ ★类型检查★ │
│ ✓ ★覆盖所有代码路径★(不用写用例) │
│ ✓ ★重构时立刻发现签名不匹配★ │
│ ✓ ★IDE 补全和跳转★ │
│ ✗ ★不验证逻辑正确性★(类型对但算错了照样过) │
│ ✗ ★不发现运行时问题★(None 从外部来、数据格式变了) │
├──────────────────────────────────────────────────────┤
│ ★测试★ │
│ ✓ ★验证行为正确★ │
│ ✓ 覆盖运行时和集成问题 │
│ ✗ ★只覆盖你写到的路径★ │
│ ✗ 写起来慢 │
└──────────────────────────────────────────────────────┘
★ ★典型分工:★
- ★"参数类型对不对、字段名有没有拼错、重构有没有漏改" → 类型检查★
- ★"业务规则对不对、边界行为、集成是否正常" → 测试★
★ ★有了类型检查,某些测试可以不写★:
✗ def test_returns_string(): assert isinstance(f(), str)
→ ★类型检查已经保证了★
★ ★和 ruff 的配合★:
★ruff★:★格式化 + lint(速度极快)★
★mypy★:★类型检查★
★ 两者不重叠:ruff ★不做类型推断★
# pyproject.toml
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "ANN"]
★"ANN"★ # ★★flake8-annotations:要求有注解(但不检查正确性)★★
★ ★pre-commit 集成★:
# .pre-commit-config.yaml
- repo: https://github.com/pre-commit/mirrors-mypy
hooks:
- id: mypy
★additional_dependencies: [types-requests, pydantic]★
★args: [--strict]★
★ ✗ ★注意:pre-commit 只检查改动的文件 → 可能漏掉影响★
✓ ★CI 里跑全量★
★ ★运行时校验的边界(★重要区分★)★:
★ ★类型检查管不到"外部来的数据"★:
- ★HTTP 请求体★
- ★JSON 文件★
- ★数据库返回★
- ★环境变量★
★ ★这些必须运行时校验★ → ★Pydantic★
★ ★典型架构:★
★边界处 Pydantic 校验 → 内部全是有类型的对象 → mypy 保证正确使用★
四个检查器的选择:mypy 生态最成熟但慢、pyright(Pylance 内核)快很多且对新 PEP 支持更早、ty 和 pyrefly 是新兴的 Rust 实现。现实中很多团队 IDE 用 pyright、CI 用 mypy——但两者报错不完全一致可能打架,要选一个作为权威。类型检查和测试是互补的:类型检查覆盖所有代码路径但不验证逻辑,测试验证逻辑但只覆盖写到的路径——分工是「参数类型、字段拼写、重构漏改」归类型检查,「业务规则、边界行为、集成」归测试。有了类型检查,assert isinstance(f(), str) 这类测试就可以不写了。最后一个重要区分:类型检查管不到「外部来的数据」(HTTP 请求、JSON、数据库、环境变量)——这些必须用 Pydantic 做运行时校验,典型架构是「边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用」。
六、实践清单
★ ★新项目的推荐配置★:
[tool.mypy]
python_version = "3.12"
★strict = true★
warn_unused_ignores = true
show_error_codes = true
pretty = true
exclude = ["migrations/", "build/"]
plugins = ★["pydantic.mypy"]★ # ★★Pydantic 插件★★
[[tool.mypy.overrides]]
module = ["tests.*"]
★disallow_untyped_defs = false★ # ★测试可以放宽(有争议)★
★ ★遗留项目的落地步骤★:
① ★先跑起来★:mypy . 看有多少错
② ★全局 ignore_errors,只让 CI 能过★
③ ★新代码强制注解★(pre-commit + ANN 规则)
④ ★按模块逐个开启★(豁免列表越来越短)
⑤ ★核心模块优先★(业务逻辑 > 工具函数 > 脚本)
⑥ ★最终 strict★
★ 检查清单:
【配置】
□ ★CI 里跑了 mypy(否则注解会腐烂)★
□ ★开了 warn_unused_ignores★
□ ★show_error_codes(便于精确 ignore)★
□ ★遗留模块用 overrides 而不是全局放宽★
□ ★缓存 .mypy_cache 加速 CI★
【代码】
□ ★用现代语法(int | None、list[str])★
□ ★避免 dict[str, Any](用 TypedDict/dataclass)★
□ ★Optional 记得收窄★
□ ★装饰器用 ParamSpec 保留签名★
□ ★ignore 带错误码 + 注释原因★
□ ★外部数据用 Pydantic 校验(类型检查管不到)★
【设计】
□ ★依赖用 Protocol 而不是具体类★
□ ★NewType 区分同为 str 的不同语义★
□ ★Literal / Enum 限定取值★
□ ★用联合类型让非法状态无法表示★
★ ★★常见问题速查★★:
┌────────────────────────────────────┬──────────────────┐
│ mypy 说没问题但线上报 AttributeError │ ★Any 传播/没检查★ │
│ Success 但函数体明显有问题 │ ★★函数没注解★★ │
│ 第三方库全是 error │ ★装 types-xxx★ │
│ Optional 报 union-attr │ ★没收窄★ │
│ 装饰器后类型全丢 │ ★用 ParamSpec★ │
│ IDE 不报错但 CI 报 │ ★pyright vs mypy★ │
│ mypy 很慢 │ ★缓存 + 增量★ │
└────────────────────────────────────┴──────────────────┘
★ 一句话总结:
★"类型注解运行时几乎不做事,价值全靠静态检查器兑现——
所以必须进 CI,否则会腐烂;
mypy 默认极其宽松(无注解的函数体根本不检查、Any 会像病毒一样传播),
『没报错』常常是『没检查』;
落地靠渐进式:新代码 strict + 遗留模块 overrides 豁免,逐个收复;
类型检查和测试互补——前者覆盖所有路径但不验证逻辑,
而外部数据必须用 Pydantic 做运行时校验。"★
新项目直接 strict = true 并加 Pydantic 的 mypy 插件。遗留项目按六步落地,核心是「新代码强制 + 遗留模块豁免 + 逐个收复」。问题速查表里最有价值的两条:「mypy 说没问题但线上报 AttributeError」是 Any 传播或根本没检查、「Success 但函数体明显有问题」是函数没注解。
记忆钩子:「★Python 的类型注解在运行时几乎不做任何事★——
def f(x: int) -> str: return x传字符串、返回 int 都不报错,Python 只是存进 annotations,★除非有库主动去读(Pydantic 校验、FastAPI 解析参数、dataclass 生成 init)★;★所以注解的价值全靠静态检查器兑现,必须进 CI 否则会腐烂★。★两个关键认知★:★① mypy 默认极其宽松——没有类型注解的函数体根本不会被检查★(要 —check-untyped-defs),所以★『Success: no issues found』常常是『根本没检查』的假象★;★② Any 会像病毒一样传播★——任何来自无类型第三方库的值都是 Any,而 ★Any 参与的任何操作结果还是 Any★(属性访问、方法调用、运算全部合法),★一个 Any 能让整条调用链的检查失效★ → 对策是★在边界处收敛★(外部数据立刻用 Pydantic 校验或 cast 成具体类型)。★落地靠渐进式★:先跑起来(全局 ignore_errors)→ ★新代码 disallow_untyped_defs + 遗留模块用 [[tool.mypy.overrides]] 豁免★ → 逐模块去掉豁免(★豁免列表越来越短★)→ 最终 ★strict = true★;★把类型覆盖率当作和测试覆盖率并列的指标★。常见报错:★Optional 最高频★(x: str = None现在直接报错要写str | None、user.name在User | None上报 union-attr 要先收窄)、★dict[str, Any] 泛滥是检查失效的元凶(用 TypedDict 或 dataclass 给它结构)★、★装饰器会丢失类型(用 ParamSpec 保留签名)★、第三方库没存根就装 types-xxx。★逃生舱三条纪律:ignore 必须带错误码、开 warn_unused_ignores 清理过期的、旁边写注释说明原因★。类型设计的高价值实践:★Protocol 结构化子类型比 ABC 更 Pythonic(不用继承、测试的 Fake 天然满足、依赖能力而非血缘)★、★NewType 区分同为 str 的不同语义(AccountId vs UserId 传反会被拦住)★、★Literal/Enum 限定取值★、★最高阶是用联合类型让非法状态无法表示(PendingOrder | PaidOrder,『已支付但没有支付时间』就无法构造)★。★类型检查和测试互补★:★前者覆盖所有代码路径但不验证逻辑,后者验证逻辑但只覆盖写到的路径★;★类型检查管不到外部来的数据(HTTP 请求、JSON、数据库、环境变量),这些必须 Pydantic 运行时校验★——典型架构是★边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用★。工具选择:★mypy 生态最成熟但慢,pyright(Pylance 内核)快很多且对新 PEP 支持更早★,★两者报错不完全一致会打架,要选一个作为权威★。」
七、常见误区与追问
- 误区:写了类型注解,传错类型时程序会报错。 完全不会。Python 的类型注解是「给工具看的元数据」,解释器执行时只是把它们存进
__annotations__字典,不做任何检查——def f(x: int) -> str: return x这个函数,你传字符串进去、它返回整数,运行时一切正常。真正会消费注解的只有几类:Pydantic(用它做运行时校验和类型转换)、FastAPI(用它解析请求参数、生成 OpenAPI)、dataclasses/attrs(用它生成__init__)、以及静态检查器(mypy、pyright,在你的代码运行之前)。所以「加了注解」和「类型安全」之间隔着一个必要条件:必须在 CI 里跑静态检查。否则注解不但没有保护作用,还会逐渐腐烂成错误的文档——代码改了但注解没改,读的人反而被误导。如果确实需要运行时校验,边界处用 Pydantic,或者用beartype/typeguard这类装饰器(有性能开销)。 - 误区:mypy 跑完显示「Success: no issues found」,说明代码类型没问题。 很可能是根本没检查。mypy 的默认配置极其宽松,最关键的一条是:没有类型注解的函数,它的函数体完全不会被检查。所以一个几百行、内部到处是属性访问和方法调用的老函数,只要签名上没写注解,mypy 就整个跳过——你得到的「Success」毫无意义。第二个原因是
Any的传播:如果某个第三方库没有类型信息,从它拿到的所有值都是Any,而Any上做任何操作 mypy 都认为合法,一个Any能让整条调用链的检查失效。验证方法:跑mypy --html-report看真实的类型覆盖率,或者开check_untyped_defs = true看看错误数会不会暴涨。「零错误」应该配合「高类型覆盖率」一起看,就像测试通过要配合覆盖率一起看一样。 - 误区:
Dict[str, Any]挺方便的,处理 JSON 数据用它就行。 它是类型检查失效的元凶。data: dict[str, Any]意味着:data["anything"]返回Any、data["x"].whatever.chain()也不报错、拼错 key 更是发现不了——你写了类型注解,但检查器什么都查不出来。而且Any会继续往下传播:这个函数返回dict[str, Any],调用方拿到的又是一堆Any。三个改进方向:①TypedDict——给字典加上结构(class Config(TypedDict): host: str; port: int),拼错 key 会被报错,而且不改变运行时行为(它还是普通 dict);② dataclass 或 Pydantic 模型——如果这个数据在系统里流转,转成对象更清晰;③ 如果确实是任意结构的 JSON,至少用object而不是Any(object上做任何操作都要先收窄,能强制你显式处理)。经验法则:看到dict[str, Any]就问一句「这个字典的结构真的不确定吗」。 - 误区:遗留项目太大,加 mypy 不现实。 正是遗留项目最需要,而且有成熟的渐进式路径。核心工具是
[[tool.mypy.overrides]]——它允许你对不同模块设置不同的严格度。落地路线:① 先让它能跑——零配置执行一次看看错误规模,然后全局ignore_errors = true让 CI 先绿;② 新代码强制——把disallow_untyped_defs = true设为全局默认,同时把所有现存模块加进 overrides 豁免列表,这样新写的代码必须有注解,老代码不受影响;③ 逐模块收复——每个迭代挑一两个模块,从豁免列表里删掉、修完报错、合并,豁免列表会越来越短(这个列表本身就是进度条);④ 优先级——核心业务逻辑 > 公共工具函数 > 边缘脚本,因为类型检查在「被大量复用的代码」上收益最高。整个过程可以持续几个月,关键是「新代码不再欠债」+「老代码稳步偿还」。 - 误区:装饰器加上去之后类型就没了,只能忍。 用
ParamSpec可以完整保留签名(Python 3.10+,更早版本可以从typing_extensions导入)。问题的根源是:def my_decorator(f): def wrapper(*a, **kw): return f(*a, **kw); return wrapper里,wrapper的签名是(*args, **kwargs)——mypy 只能推断出被装饰后的函数「接受任意参数」,于是调用时传错参数、传错类型都不报错,而且返回值也变成Any。正确写法是用ParamSpec捕获参数签名、TypeVar捕获返回类型:P = ParamSpec("P"); R = TypeVar("R"),然后def my_decorator(f: Callable[P, R]) -> Callable[P, R],内部def wrapper(*a: P.args, **kw: P.kwargs) -> R。这样被装饰的函数签名完全不变,IDE 补全和 mypy 检查都正常。如果装饰器会改变签名(比如注入一个参数),可以用Concatenate。这是一个高价值的改进,因为装饰器往往用在项目最核心的地方(认证、缓存、重试、事务)。 - 追问:mypy 和 pyright 该选哪个? 两者可以共存,但要明确谁是「权威」。mypy 是 PEP 类型系统的官方参考实现,配置最灵活、文档最全、生态插件多(Pydantic、Django、SQLAlchemy 都有官方插件),缺点是慢(大项目全量检查几十秒到几分钟,虽然有增量缓存)。pyright 是微软用 TypeScript 写的,也是 VS Code 里 Pylance 的内核——速度快得多、对新 PEP 的支持通常更早、类型收窄的推断更聪明(比如对
assert、match、自定义TypeGuard的处理),缺点是配置和 mypy 不完全兼容、某些边缘行为有差异。现实中最常见的组合是:开发者在 VS Code 里享受 pyright 的即时反馈,CI 里跑 mypy 作为门禁。但要注意两者的报错不完全一致——可能出现「IDE 不报错但 CI 挂了」或反过来,容易让人困惑。所以建议明确一个作为权威(通常是 CI 里跑的那个),另一个只当辅助、报错不一致时以权威为准。新兴的ty(ruff 团队)和pyrefly(Meta)都是 Rust 实现、速度极快,但还在早期,值得关注但暂时不适合作为唯一依赖。 - 追问:有了类型检查,还需要写测试吗? 需要,两者覆盖的是完全不同的风险。类型检查的优势是「覆盖所有代码路径而不需要写用例」——它能保证「这个函数的所有调用点传的参数类型都对」「重构改了签名后所有调用方都同步了」「字段名没拼错」,这些用测试来保证成本极高(要写大量用例才能覆盖所有分支)。但它完全不验证逻辑正确性——
def add(a: int, b: int) -> int: return a - b类型完美但结果全错。测试的优势是验证行为,但只能覆盖你写到的路径。所以分工很清晰:「参数类型、字段拼写、重构是否漏改、接口是否兼容」交给类型检查;「业务规则、边界行为、集成是否正常」交给测试。一个实际的好处是:有了类型检查,某些测试可以不写了——def test_returns_string(): assert isinstance(f(), str)这种「验证返回类型」的测试完全是浪费。另外还有一类风险两者都覆盖不了:外部数据的形状(API 响应变了、数据库字段改了、配置文件写错了)——这必须靠运行时校验(Pydantic)。 - 追问:
# type: ignore用多了会不会让类型检查失效? 会,所以要有纪律。裸的# type: ignore会抑制那一行的所有类型错误——包括你本意之外的、以及以后新出现的。三条实践规则:① 必须带错误码——# type: ignore[arg-type]只忽略这一种错误,如果同一行以后出现了别的问题(比如attr-defined),mypy 仍然会报出来。这需要开启show_error_codes = true(新版本默认开)。② 开启warn_unused_ignores = true——当某个ignore因为库更新、代码改动而不再需要时,mypy 会提醒你删掉它;没有这个选项,过期的 ignore 会永久留在代码里,掩盖后来出现的真实问题。③ 旁边写注释说明为什么——# type: ignore[no-any-return] # third-party lib returns Any, validated by pydantic below。此外还要区分ignore和cast:cast(User, raw)表达的是「我知道它是 User,只是 mypy 推不出来」(语义更明确、后续检查仍然生效),而ignore是「别管这里」——能用cast就别用ignore。
八、加强记忆
Python 的类型注解在运行时几乎不做任何事——def f(x: int) -> str: return x 传字符串、返回 int 都不会报错,Python 只是把它存进 __annotations__,除非有库主动去读(Pydantic 校验、FastAPI 解析参数、dataclass 生成 __init__);所以注解的价值全靠静态检查器兑现,必须进 CI,否则会腐烂成错误的文档。两个关键认知:① mypy 默认极其宽松——没有类型注解的函数体根本不会被检查(要开 check_untyped_defs),所以**「Success: no issues found」常常是「根本没检查」的假象**;② Any 会像病毒一样传播——任何来自无类型第三方库的值都是 Any,而 Any 参与的任何操作结果还是 Any(属性访问、方法调用、运算全都合法),一个 Any 能让整条调用链的检查失效 → 对策是在边界处收敛(外部数据立刻用 Pydantic 校验或 cast 成具体类型)。落地靠渐进式:先跑起来(全局 ignore_errors)→ 新代码 disallow_untyped_defs + 遗留模块用 [[tool.mypy.overrides]] 豁免 → 逐模块去掉豁免(豁免列表越来越短,它本身就是进度条)→ 最终 strict = true;把类型覆盖率当作和测试覆盖率并列的指标。常见报错:Optional 最高频(x: str = None 现在直接报错、要写 str | None;user.name 在 User | None 上报 union-attr、要先收窄)、dict[str, Any] 泛滥是检查失效的元凶(用 TypedDict 或 dataclass 给它结构)、装饰器会丢失类型(用 ParamSpec 保留签名)、第三方库没存根就装 types-xxx。逃生舱的三条纪律:ignore 必须带错误码、开 warn_unused_ignores 清理过期的、旁边写注释说明原因(另外能用 cast 就别用 ignore)。类型设计的高价值实践:Protocol 结构化子类型比 ABC 更 Pythonic(不用继承、测试的 Fake 天然满足、依赖的是能力而非血缘)、NewType 区分同为 str 的不同语义(AccountId vs UserId,传反了会被拦住)、Literal/Enum 限定取值、最高阶的是用联合类型让非法状态无法表示(PendingOrder | PaidOrder,「已支付但没有支付时间」就无法构造)。类型检查和测试互补:前者覆盖所有代码路径但不验证逻辑,后者验证逻辑但只覆盖写到的路径;类型检查管不到外部来的数据(HTTP 请求、JSON、数据库、环境变量),这些必须用 Pydantic 做运行时校验——典型架构是边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用。工具选择上:mypy 生态最成熟但慢,pyright(Pylance 内核)快很多且对新 PEP 支持更早,两者报错不完全一致会打架,要明确选一个作为权威。