Python · 类型注解与静态检查

注解默认"只是注释" — 存进 __annotations__ 但解释器不检查; mypy/pyright 在 CI 静态拦错零开销, pydantic 在边界运行时校验, 双通道护住动态语言

静态读取 同源建模型 输出 输出 静态通道 · mypy / pyright 读源码里的注解做类型推理, 不运行代码 CI 阶段拦住类型错误, 运行时零开销 IDE 补全 / 安全重构 / 找引用全靠它 $ mypy --strict app/ → exit 1 阻断合并 带注解的签名 — 双通道同源 def transfer(src: Account, amount: Decimal) -> Receipt: 注解只是元数据: 存进 __annotations__, 解释器默认不检查 — 传错类型照样跑 想让它"长牙": 左边接静态检查器, 右边接校验框架 运行时通道 · pydantic 同一套注解再利用: BaseModel 字段携带约束 外部输入实例化时校验 ValidationError → HTTP 422 CI 拦错现场 $ mypy --strict app/transfer.py error: Argument 2 to "transfer" has incompatible type "str"; expected "Decimal" [arg-type] 合并前挡住, 线上根本跑不到这一步 分工 — 各守一关 静态通道: 管内部代码一致性 调用方传错类型, CI 就红 运行时通道: 管外部数据合法性 请求体 / 配置 / 消息, 在边界建模 边界拦错现场 TransferRequest( amount="abc") ValidationError: amount could not parse as Decimal 脏数据进系统的第一道门 写一次注解, 两处消费: CI 拦内部类型错误, 边界拦外部脏数据 — 动态语言的双保险 注解演进时间线 — 从"能注"到"好用" 3.5 typing 模块 List[int] / Dict[str, T] 先解决"有没有" 3.9 内置泛型 list[int] 不用 import 告别 List 大写字 3.10 | 运算符 + match int | None 替代 Optional 联合类型写进语法 3.12 PEP 695 type X[T] = ... 声明 TypeVar 成为历史 Legend 静态检查通道 注解本体 (元数据) 运行时校验通道 拦错现场 演进 / 说明

注解只是元数据

  • • 存进 __annotations__, 解释器默认一个字都不检查
  • • 传错类型照跑, 直到深处 AttributeError 才炸
  • • from __future__ import annotations 让注解延迟求值
  • • 想让注解"长牙"必须外接工具: 静态或运行时

静态通道零开销

  • • mypy/pyright 在 CI 拦错, 运行时成本是零
  • • IDE 跳转/安全重命名/找引用全靠注解锁死
  • • 存量库渐进收网: strict 只对新模块开
  • • 管的是内部代码一致性 — 你自己写的调用

运行时通道管边界

  • • 外部输入 (请求/配置/消息) 全走 pydantic 模型
  • • 非法数据在门口抛 ValidationError → 422
  • • 内部代码信任注解, 不做双重校验
  • • FastAPI 集成: 校验 + OpenAPI 文档一站出

💡 一句话理解

类型注解是写在代码里的"合同条款": Python 解释器只负责把条款存档(__annotations__), 自己从来不执行合同。要让它生效有两条路: 左边把合同交给 mypy/pyright, 在 CI 里静态审合同, 错误合并前就拦下, 运行时零开销; 右边把合同交给 pydantic, 在系统边界(请求体、配置、消息)运行时逐字验货, 非法数据当场抛 ValidationError。双通道合起来, 动态语言的灵活性没丢, 又补上了静态语言的兜底。

🧠 必知必会 必考 & 必会

__annotations__
函数/模块/类的注解都存进这个属性, 但解释器默认不检查。运行时要取回真实类型用 typing.get_type_hints()(会解析字符串注解)。
def f(x: int) -> str: ...
print(f.__annotations__)  # → {'x': <class 'int'>, 'return': <class 'str'>}
# 关键: 只存档不检查; 取回真实类型用 get_type_hints(f)
future 延迟求值
from __future__ import annotations 把所有注解变成字符串, 定义时不求值 — 前向引用不用加引号, 循环导入也解掉大半。
from __future__ import annotations
class Node:
    next: Node | None       # 关键: 引用自身不用加引号
    # 所有注解变字符串延迟求值, 前向引用/循环导入大半解掉
Optional 三值语义
X | None 是类型并集, None 本身也是类型。参数想"可不传"必须写 = None 默认值; 只写 Optional 不给默认, 参数照样必传。
def f(x: int | None): ...     # 只可空, x 仍必传
f()  # → TypeError: missing 1 required positional argument
def g(x: int | None = None): ...  # 对: 可空+可不传两件套
Protocol
结构化鸭子类型: 只要方法形状对就算实现, 不需要继承。第三方类一行不改就能满足; runtime_checkable 后还能 isinstance(只查方法存在)。
class Readable(Protocol):
    def read(self, n: int) -> bytes: ...
class LegacyFTP:
    def read(self, n: int) -> bytes: ...   # 没继承也算实现
def fetch(res: Readable): ...
fetch(LegacyFTP())  # mypy: OK  形状匹配即可
TypedDict vs dataclass
TypedDict 给 dict 结构标注键值类型(运行时仍是普通 dict, 不校验); dataclass 是带默认值/相等比较的轻量类。JSON 边界要校验用 pydantic。
class DBConfig(TypedDict):
    host: str; port: int
cfg: DBConfig = {"host": "db1", "port": 3306}
cfg["port"] = "3306"  # mypy: error  运行时不拦, 仍是 dict
TypeVar / 泛型
T = TypeVar("T") 让 Repository[T] 进什么类型出什么类型; 3.12 起 class Repo[T] 直接写, 不用再声明 TypeVar。
T = TypeVar("T")
def first(xs: list[T]) -> T: return xs[0]
n = first([1, 2])       # → 推导为 int, 进出同型
class Repo[T]:          # 3.12+: 直接写泛型类, 不用 TypeVar
    ...
Literal / Enum
把取值收窄到枚举过的集合, mypy 能做穷尽性检查(漏分支报错), 配合 match 写状态机最稳; Literal 管"就是这几个字符串"。
def set_mode(m: Literal["r", "w"]) -> None: ...
set_mode("x")  # mypy: error: 不在 "r"/"w" 里
# Enum 同理收窄取值, match + assert_never 还能查漏分支
cast
对检查器的"人工断言", 零运行时作用。用在 ORM 行、JSON 解析这类驱动只给你 Any 的边界, 确认过 schema 再收窄。
row = conn.execute(sql).fetchone()  # 驱动只给 Any
oid = cast(int, row[0])   # 关键: 确认 schema 后人工收窄
oid + 1                # 零运行时开销, 检查器从此认 int
type: ignore 纪律
每条必须带错误码 # type: ignore[arg-type] 并配 warn_unused_ignores — ignore 一旦失效立刻变新告警, 防止永久遮蔽真错。
legacy()  # type: ignore[no-any-return]  错误码必带
# warn_unused_ignores = true:
# 上游修好后这条 ignore 失效, 自己反过来变告警
ParamSpec
装饰器的签名救星: Callable[P, R] 把被装饰函数的参数/返回类型原样透传, 打点/重试/缓存包装后 IDE 补全一点不丢。
P = ParamSpec("P"); R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]: ...
# 关键: 被 @timed 的函数参数/返回类型原样透传, 补全不丢
Annotated
Annotated[Decimal, Field(gt=0)] 类型 + 元数据一层写完; pydantic 约束、FastAPI 依赖注入都从这读元数据, 检查器只看第一个参数。
Amount = Annotated[Decimal, Field(gt=0)]
class Order(BaseModel):
    amount: Amount         # pydantic 读 Field: 校验 > 0
# 检查器只看第一个参数 Decimal, 元数据不影响类型
Final / ClassVar
Final 声明"不再重新绑定"(静态可查, 运行时不拦); ClassVar 标记类变量, 防止 pydantic 把它当字段收集。
MAX_RETRIES: Final = 3
MAX_RETRIES = 5     # mypy: error, 但运行时照常改
class C:
    counter: ClassVar[int] = 0  # pydantic 不当字段收集

🏭 生产实战 real world

场景 1 · 30 万行存量库渐进接入 mypy, 不停业务迭代

一次性全量 strict 等于一次报出五万个错, 没人敢合。正解是配置分层: 新模块最严, 旧模块先豁免再逐步收网:

# pyproject.toml — 渐进式收网: 新代码 strict, 旧代码先放行
[tool.mypy]
python_version = "3.11"
plugins = ["pydantic.mypy"]          # 不装插件: pydantic 字段在 mypy 眼里全是 Any
warn_unused_ignores = true           # ignore 失效即告警, 防止永久遮蔽真错

[[tool.mypy.overrides]]
module = "legacy.*"                  # 存量代码: 先整体豁免, 按目录逐个摘掉
ignore_errors = true

[[tool.mypy.overrides]]
module = "app.*"                     # 新模块一律最严
disallow_untyped_defs = true
disallow_any_generics = true

CI 里 mypy 失败即红灯。三个月后 legacy 豁免名单从 47 个目录收到 6 个, 每次摘掉一个都有静态检查兜底。

场景 2 · pydantic 定义 API 请求/响应模型, FastAPI 自动 422

校验逻辑散在视图函数里既难审又难测。把契约写成模型, 校验、文档、错误格式一次到位:

from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict

class TransferRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")     # 未知字段直接拒: 防客户端塞脏参数
    src: str = Field(min_length=8, max_length=8)  # 约束跟类型写在一起, 一眼可审
    amount: Decimal = Field(gt=Decimal("0"), max_digits=12)

class TransferResponse(BaseModel):
    receipt_no: str
    balance: Decimal

@app.post("/transfer", response_model=TransferResponse)
def transfer(req: TransferRequest):               # 进函数体时已是校验过的强类型对象
    ...
# amount=-1 或多余字段: 自动 422 + 字段级报错, OpenAPI 文档同步生成

场景 3 · Protocol 解耦第三方资源, 老类一行不改

想让函数接受"任何可读可关的资源"。继承抽象基类要改别人代码, Protocol 只认形状:

from typing import Protocol

class Readable(Protocol):          # 结构化类型: 只看方法形状, 不要求继承
    def read(self, n: int) -> bytes: ...
    def close(self) -> None: ...

def fetch_header(res: Readable):    # 文件/socket/自研连接/测试替身都能传
    try:
        return res.read(16)
    finally:
        res.close()

class LegacyFTP:                     # 十年前老类, 零改动即满足 Readable
    def read(self, n: int) -> bytes: ...
    def close(self) -> None: ...

场景 4 · TypedDict 约束 JSON 配置文件

配置是 dict 不是类, 但字段名打错、类型给错这类问题完全可以让静态检查器先抓一遍:

from typing import TypedDict, Literal
import json, pathlib

class DBConfig(TypedDict):
    host: str
    port: int
    engine: Literal["mysql", "postgres"]   # 写 "mongo" 直接红线

class AppConfig(TypedDict, total=False):    # total=False: 所有键都可选
    db: DBConfig
    debug: bool

cfg: AppConfig = json.loads(pathlib.Path("app.json").read_text())
cfg["port"] = "3306"      # mypy 立刻报: str 塞不进 int 槽位
# 注意: TypedDict 运行时不校验 — 配置来源不可信时要换 pydantic 解析

场景 5 · Literal + Enum 收窄状态机, 配 match 穷尽检查

状态值用裸字符串, 新增状态后总有分支被漏。枚举 + assert_never 让"漏写分支"变成静态错误:

import enum
from typing import assert_never

class OrderStatus(str, enum.Enum):
    PAID = "paid"
    SHIPPED = "shipped"
    DONE = "done"

def next_action(s: OrderStatus) -> str:
    match s:
        case OrderStatus.PAID:    return "notify_warehouse"
        case OrderStatus.SHIPPED: return "watch_carrier"
        case OrderStatus.DONE:    return "archive"
        case _:
            assert_never(s)   # 明天加 REFUNDED 忘了写分支: mypy 当场报错

场景 6 · 泛型仓储类 Repository[T] 一套代码全实体复用

User 仓储、Order 仓储除了实体类型什么都不差。泛型把"进出同型"写进合同:

from typing import Generic, TypeVar

T = TypeVar("T")

class Repository[T]:                      # 3.12 语法; 老版本写 Generic[T]
    def __init__(self, model: type[T]) -> None:
        self.model = model

    def get(self, oid: int) -> T | None: ...
    def save(self, obj: T) -> None: ...

user_repo: Repository[User] = Repository(User)
u = user_repo.get(42)          # u 推导为 User | None, 属性补全全有
user_repo.save(Order(...))     # mypy: Repository[User] 不收 Order

场景 7 · ParamSpec 让打点装饰器不丢签名

监控装饰器如果签名写 *args: Any, 被包函数的类型信息全灭。ParamSpec 原样透传:

from typing import ParamSpec, TypeVar, Callable
import time, functools

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrap(*args: P.args, **kwargs: P.kwargs) -> R:   # 签名原样透传
        t0 = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            metrics.observe(fn.__name__, time.perf_counter() - t0)
    return wrap
# 被 @timed 的函数: 参数/返回值类型一个不丢, IDE 补全照常

场景 8 · cast 收窄 ORM 边界 + type: ignore 的纪律

数据库驱动只会返回 Any, 放任不管 Any 会一路传染。边界处人工确认后收窄:

from typing import cast
from decimal import Decimal
from sqlalchemy import text

row = conn.execute(text("SELECT id, amount FROM orders WHERE id=:o"),
                   {"o": 42}).fetchone()
# 驱动层没有类型信息, 返回 Any — 这是 Any 入侵的第一站
oid = cast(int, row[0])          # 确认过 schema 后手动收窄, 零运行时开销
amount = cast(Decimal, row[1])

total = legacy_sum(raw)  # type: ignore[no-any-return]
# ignore 必须带错误码 + 限期修; warn_unused_ignores 会盯着它失效

场景 9 · pydantic v2 性能敏感路径: TypeAdapter 批量校验

事件管道每秒几十万条, 逐条构造模型开销可观。pydantic-core 的批量入口快一个量级:

from pydantic import TypeAdapter, BaseModel, ConfigDict
from decimal import Decimal

class Event(BaseModel):
    model_config = ConfigDict(frozen=True)   # 不可变: 可哈希可共享, 校验后零拷贝
    user_id: int
    amount: Decimal
    tag: str | None = None

adapter = TypeAdapter(list[Event])           # 编译一次, 别在循环里建模型
events = adapter.validate_python(raw_batch)  # pydantic-core (Rust) 批量校验
# 100 万条: 逐条 Event(**x) 8.9s → 批量 2.1s; pydantic v1 同款 21s

场景 10 · 事故复盘: 注解没拦住 None, 深处 AttributeError

结账接口偶发 500, 栈指向最深处。根因是注解只是注释, 而静态通道没开、边界没校验:

# 事故现场: coupon 可能为 None, 但注解写着 Coupon, 解释器根本不查
# def apply_coupon(order, coupon): return order.total * (1 - coupon.rate)
# → AttributeError: 'NoneType' object has no attribute 'rate' (深处才炸)

# 复盘后双通道补齐:
def apply_coupon(order: Order, coupon: Coupon | None) -> Decimal:
    if coupon is None:                   # 类型收窄: 这行之后 coupon 就是 Coupon
        return order.total
    return order.total * (1 - coupon.rate)

class CouponPayload(BaseModel):        # 外部入参先过边界模型再进领域函数
    coupon_code: str | None
# 再配 CI 里的 mypy --strict: 传 None 给 Coupon 参数在合并前就红

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 注解不是运行时校验 — 参数注解写 int 传字符串照跑, 直到深处 AttributeError/TypeError 才炸, 而且栈不在出错入口。正解: 外部数据在边界过 pydantic, 内部代码靠 mypy strict 兜底, 别指望解释器。
# 错: def add(a: int, b: int) -> int: ...
add("1", "2")  # → '12'  解释器根本不查注解
# 对: 外部数据先过 pydantic, 内部靠 mypy --strict
坑 2 · Optional[X] 不等于"可空参数有默认值" — 只写 def f(x: int | None) 不带 = None, 调用 f() 依然 TypeError missing argument。正解: 可空可选要两件套 x: int | None = None; 只可空必传就保持无默认。
# 错: def f(x: int | None): ...  没给默认值
f()  # → TypeError: missing 1 required positional argument: 'x'
# 对: def f(x: int | None = None): ...  可空+可不传
坑 3 · list 与 List 混用的版本坑 — list[int] 要 3.9+, 在 3.8 运行时直接 TypeError('type' object is not subscriptable)。正解: 老版本用 typing.List 或文件头加 from __future__ import annotations(只救注解位置, 不救运行时取值)。
# 错: 3.8 上运行时取值 list[int]
#     → TypeError: 'type' object is not subscriptable
from __future__ import annotations
xs: list[int] = []  # 对: 延迟求值后 3.8 也能写注解
坑 4 · pydantic v1/v2 API 大迁移 — 升级后满屏 User.dict() AttributeError。v2 改成 model_dump()/model_validate(), Config 类变 model_config。正解: 全局替换 + 开 pydantic.mypy 插件让静态检查先揪出旧调用。
# 错(v1 写法): User(**data).dict() → v2 AttributeError
u = User.model_validate(data)   # 对: v2 入口
u.model_dump()                  # 对: v2 导出; Config → model_config
坑 5 · type: ignore 滥用掩盖真错 — 一行 ignore 能压掉整行的所有错误, 连新引入的 bug 一起压。正解: 每条带错误码 # type: ignore[arg-type], 开 warn_unused_ignores, 让修好后残留的 ignore 反过来变告警。
# 错: legacy()  # type: ignore  — 整行所有错全被压掉
legacy()  # type: ignore[no-any-return]  对: 带错误码
# warn_unused_ignores = true → 失效 ignore 反变告警
坑 6 · 循环导入因 TYPE_CHECKING 未用 — models.py 与 services.py 互相 import 类型注解, 运行时 ImportError。正解: if TYPE_CHECKING: from .models import User, 配 from __future__ import annotations, 检查器看得见, 运行时不执行。
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .models import User   # 对: 检查器看得见
def svc(u: "User") -> None: ...  # 运行时不执行, 解掉环
坑 7 · 前向引用没加引号 — 类体内注解引用自身或后定义类型, 定义时 NameError。正解: 加引号 "Node" 或文件头统一 from __future__ import annotations 一劳永逸(所有注解延迟求值)。
class Node:
    def nxt(self) -> "Node | None": ...  # 对: 加引号
    # 错: -> Node | None — 定义时 Node 还不存在, NameError
坑 8 · TypedDict 当 pydantic 用 — TypedDict 运行时就是普通 dict, JSON 里塞 "port": "3306" 照样进门, 深处才炸。正解: TypedDict 只管静态; 不可信来源(外部配置/请求体)一律 pydantic BaseModel 解析。
class DBConfig(TypedDict): port: int
cfg = json.loads('{"port": "3306"}')  # 错: 不校验进门
cfg["port"] + 1  # → TypeError: 深处才炸
# 对: 外部数据用 pydantic BaseModel 解析, 门口就 422
坑 9 · mypy 插件缺失, ORM 返回 Any 全线失守 — SQLAlchemy 查询结果在 mypy 眼里是 Any, Any 会传染整条调用链, 等于白配。正解: 装官方插件 plugins = ["sqlalchemy.ext.mypy.plugin"](或 2.0 typing 扩展), pydantic 同理配 pydantic.mypy。
# 错: 不装插件 — ORM 查询结果全是 Any, 传染整条链
# pyproject.toml:
[tool.mypy]
plugins = ["pydantic.mypy"]   # 对: 官方插件
# SQLAlchemy 同理: sqlalchemy.ext.mypy.plugin
坑 10 · dataclass 与 pydantic 混用重复校验 — 外层 pydantic 校验一遍, 转成 dataclass 再用 props 校验一遍, 性能翻倍浪费、字段三处维护。正解: 边界模型用 pydantic, 内部值对象选 dataclass 且不再重复约束, 转换只做一次。
# 错: pydantic 校验一遍 → dataclass 再约束一遍, 字段三处维护
user = User.model_validate(raw)       # 对: 门口解析一次
stats = UserStats(uid=user.id)        # 对: 内部纯 dataclass 零校验
坑 11 · runtime_checkable 只查方法存在 — isinstance(x, Readable) 只验证有 read/close 属性, 不查签名与返回类型, 参数不匹配也过。正解: 签名正确性交给 mypy 静态检查; isinstance 只当"有没有这形状"的粗筛。
@runtime_checkable
class Readable(Protocol): ...
class Fake:  read = 42          # 属性存在但不是方法
isinstance(Fake(), Readable)  # → True  签名/类型都不查
# 对: 签名正确性交给 mypy, isinstance 只当粗筛
坑 12 · overload 实现漏写 — 只写了几个 @overload 桩函数没有裸实现, 调用时 TypeError 或 mypy 报 "overloaded function implementation is missing"。正解: overload 桩后面必须跟一个无装饰器的实现函数, 签名要覆盖所有桩。
@overload
def parse(s: str) -> dict: ...
@overload
def parse(s: bytes) -> bytes: ...
def parse(s): return json.loads(s)  # 对: 必须有裸实现
坑 13 · 类变量与实例变量注解位置错 — 类体里 x: int = 0 是类变量语义, 实例却当各自独立字段用, 一个对象改了全体可见。正解: 实例属性在 __init__ 里 self.x: int = 0; 真类变量标 ClassVar[int], 让 pydantic 也别把它当字段。
class Opt:
    timeout: int = 30     # 错: 类变量语义, 全体共享一份
# 对: __init__ 里 self.timeout: int = 30  各自独立
# 真类变量标 timeout: ClassVar[int] = 30
坑 14 · str Enum 的序列化怪异 — class S(str, Enum) 在 3.11 前 f"{member}" 输出 S.PAID 而不是 paid, 存库/拼 URL 就错。正解: 取值用 .value; 3.11+ 用 enum.StrEnum; 比较时统一先转 str 避免成员与裸串混比。
class S(str, Enum):
    PAID = "paid"
f"{S.PAID}"      # 3.11 前 → 'S.PAID', 存库就错
S.PAID.value     # 对: → 'paid'; 3.11+ 用 enum.StrEnum
坑 15 · Annotated 元数据误当注入用 — 把业务对象塞进 Annotated[X, obj] 指望框架自动注入, 只有显式支持的框架(FastAPI/pydantic)才读这些元数据。正解: 依赖注入走框架自己的 Depends 体系; Annotated 元数据只放该框架文档声明支持的类型。
# 错: Annotated[X, MyDBConn()] 自造元数据指望自动注入 — 没人读它
# 对: FastAPI 体系内才有效:
def q(db: Annotated[Session, Depends(get_db)]): ...
坑 16 · Final 常量被改 — MAX: Final = 100 运行时重新赋值照常成功, Final 只是给检查器看的。正解: 真要运行时不可变用 dataclass(frozen=True) 或 pydantic frozen 模型; CI 开 strict 让 rebind 在静态就被拦。
MAX: Final = 100
MAX = 200  # mypy: error  但运行时照常成功
# 对: 运行时不可变用 dataclass(frozen=True) / pydantic frozen
坑 17 · 结构化与名义类型混淆 — 写了 Protocol 却拿普通类做 isinstance 的类型判断, 或以为继承 Protocol 才算数。正解: 分清两套体系 — Protocol 按形状匹配(静态), ABC/普通基类按继承链匹配; runtime_checkable 的 Protocol 才能参与 isinstance。
class Proto(Protocol): ...
class Impl: ...    # 没继承, 形状对即可静态通过
isinstance(Impl(), Proto)  # 错: → TypeError (未开 runtime_checkable)
# 对: @runtime_checkable 后才可 isinstance; 继承链判断用 ABC
坑 18 · pydantic 大模型嵌套深拷贝拖垮接口 — 深嵌套模型 model_dump() 递归拷贝, 单次几十毫秒, 热路径 P99 直接劣化。正解: 只 dump 需要的字段(include=)、大列表用 TypeAdapter 批量, 内部传递直接持模型别反复 dump。
# 错: 热路径每次 order.model_dump() — 深嵌套递归拷贝
resp = order.model_dump(include={"id", "status"})  # 对
TypeAdapter(list[Event]).dump_python(events)  # 对: 批量
坑 19 · validator 抛 ValueError 之外的异常绕过收集 — field_validator 里抛 TypeError/RuntimeError, pydantic 不会聚合成 ValidationError, 直接 500 而不是 422。正解: validator 里只抛 ValueError/AssertionError; 系统级错误(如 DB 挂了)别放 validator。
# 错: validator 里 raise TypeError → 不聚合, 直接 500
@field_validator("port")
def chk(v: int) -> int:
    if v > 65535: raise ValueError("out of range")  # 对
    return v
坑 20 · 隐式 Any 静默传染 — 没开 disallow_untyped_defs, 无注解函数返回 Any, 下游推导全变 Any, 检查形同虚设。正解: strict(至少 disallow_untyped_defs + disallow_any_explicit 评审) + warn_return_any, 让 Any 显式冒头逐个消灭。
def legacy(x): return x * 2   # 错: 无注解 → 返回 Any
reveal_type(legacy(2))          # mypy → Any, 下游推导全灭
# 对: strict + disallow_untyped_defs + warn_return_any