在Python的类型系统中,Optional[str]只能表达“这个属性可能是None”这一层含义,却无法表达“这个属性是否为None取决于另一个属性”这种关联关系。比如一个支付订单对象,当支付方式是银行卡时才有cardNumber字段,当支付方式是货到付款时才有收货现金金额字段。如果简单地把两个字段都声明成Optional,类型检查器就完全无法阻止你在银行卡模式下访问收货金额这种逻辑错误。Union类型的思路是反过来:与其给每个属性加Optional标记,不如把几种合法的状态组合分别建模成一个整体类型,再用Union把它们联合起来。

为什么单纯的Optional无法表达属性关联
先看一个典型的错误示范。假设我们用dataclass定义一个订单结构:
from dataclasses import dataclass
from typing import Optional
@dataclass
class Order:
method: str
card_number: Optional[str] = None
cash_amount: Optional[float] = None这段代码的问题在于,类型系统允许出现四种状态组合:两个字段都有值、两个字段都是None、只有card_number有值、只有cash_amount有值。而业务上合法的状态只有两种。类型检查器对此毫无办法,因为从它的视角看,访问order.card_number之后你还需要再判断一次是否为None,这个判断和method是什么值没有任何关联。
更隐蔽的问题是,当代码演化到多处构造Order对象时,很容易出现某一处忘了给card_number赋值的情况。mypy只会提示你可能访问了None,但不会告诉你“银行卡订单必须有卡号”这条业务规则。错误被推迟到运行时才暴露,排查成本成倍增加。这就是所谓的“非法状态可表示”问题——类型系统允许构造出业务上不应该存在的对象。
解决这个问题的核心原则是:让非法状态无法通过类型检查。要做到这一点,就不能把关联属性拆散在同一个类里各自标注Optional,而应该把每一种合法状态建模成一个独立的类型,让属性的存在性和类型本身绑定。
用Union组合多种结构表达关联约束
最直接的实现方式是定义多个结构体,然后用Union联合。下面用Pydantic给出一个完整示例:
from typing import Union, Literal, Annotated
from pydantic import BaseModel, Field
class CardPayment(BaseModel):
method: Literal["card"]
card_number: str # 必填,不存在None的可能
bank_name: str
class CashPayment(BaseModel):
method: Literal["cod"]
cash_amount: float # 必填
courier_id: int
Payment = Union[CardPayment, CashPayment]
def handle(payment: Payment) -> str:
# 通过Literal字段做类型窄化
if payment.method == "card":
# 这里payment被窄化为CardPayment
return f"扣款卡号 {payment.card_number}"
else:
# 这里payment被窄化为CashPayment
return f"货到付款 {payment.cash_amount} 元"这段代码的关键在于Literal["card"]这个字面量类型。mypy和pyright都能根据payment.method == "card"这个判断把payment的类型收窄到CardPayment分支,此时访问card_number不需要任何None检查,因为它在这个分支里根本就不是Optional。反过来,如果你在card分支里写了payment.cash_amount,类型检查器会立刻报错,因为这个属性在CardPayment上不存在。
注意这里没有给cash_amount设置默认值,也没写Optional。属性的存在性由所属的分支类型决定,这正是Union方案和Optional方案的本质区别:约束从“每个字段各自检查”上升到了“整个对象的状态必须合法”。构造一个card_number为None的CardPayment在类型层面就是不可能的。
如果不依赖Pydantic,纯标准库也能实现同样的效果。Python 3.10之后可以直接写Payment = CardPayment | CashPayment,语义完全等价。用dataclass配合Literal同样可行:
from dataclasses import dataclass
from typing import Literal, Union
@dataclass(frozen=True)
class CardPayment:
method: Literal["card"] = "card"
card_number: str
bank_name: str
@dataclass(frozen=True)
class CashPayment:
method: Literal["cod"] = "cod"
cash_amount: float
courier_id: int
Payment = Union[CardPayment, CashPayment]TypedDict方案与窄化技巧
如果处理的是字典结构而不是对象,比如从JSON反序列化出来的数据,TypedDict配合Union是更合适的选择。PEP 646之前,社区常用“Tagged Union”模式,即在每个字典里放一个标签字段:
from typing import TypedDict, Literal, Union
class CardDict(TypedDict):
method: Literal["card"]
card_number: str
class CashDict(TypedDict):
method: Literal["cod"]
cash_amount: float
PaymentDict = Union[CardDict, CashDict]
def process(data: PaymentDict) -> None:
if data["method"] == "card":
reveal_type(data) # Union[CardDict, CashDict] 窄化受限时可用match
match data:
case {"method": "card", "card_number": card}:
print(f"卡支付: {card}")
case {"method": "cod", "cash_amount": amount}:
print(f"现金: {amount}")需要提醒的是,TypedDict的窄化支持在不同检查器之间有差异。pyright对字典字面量的判别式窄化支持较好,而mypy在早期版本中对通过键访问判断来窄化整个TypedDict的支持有限,这时用match语句的结构化模式匹配是更稳妥的写法,它对所有主流检查器都有明确的窄化语义。
另一个进阶技巧是exhaustive检查。给Union加一个带Never类型的兜底分支,可以在新增支付方式却忘了更新处理函数时获得编译期报错:
from typing import Union, NoReturn, assert_never
def handle(payment: Payment) -> str:
if payment.method == "card":
return "card"
elif payment.method == "cod":
return "cod"
assert_never(payment) # 若Union新增成员而未处理,这里会报错assert_never是Python 3.11引入的函数,收到的参数类型如果不是Never,检查器就会报错。这相当于把switch语句的完整性检查带进了Python,在Union成员较多、处理逻辑分散在多个函数里时尤其有价值。
各方案对比与选型建议
下表总结了处理关联可选属性时几种常见方案的差异:
| 方案 | 运行时校验 | 窄化支持 | 适用场景 |
|---|---|---|---|
| Optional属性堆叠 | 无 | 无关联 | 属性之间确实互不依赖 |
| dataclass + Union | 无 | 好 | 内部代码、无反序列化需求 |
| TypedDict + Union | 无 | 视检查器而定 | 处理JSON等字典数据 |
| Pydantic + Union | 有 | 好 | API边界、需要解析外部输入 |
实践中一个常见的折中做法是:在系统边界用Pydantic模型加Union接收并校验外部数据,内部传递时复用同一组类型,处理逻辑用Literal判别或match语句窄化。这样既保证了入口处的运行时校验,又让内部代码全程享受静态检查保护。
最后要注意Union成员的顺序问题。Pydantic在反序列化时会按Union中声明的顺序尝试匹配,如果两个成员结构高度相似,把更具体的类型放在前面可以避免被宽泛的类型抢先匹配。Pydantic v2还提供了Annotated[Union[...], Field(discriminator="method")]的显式判别式写法,性能和准确性都更好,建议在成员较多的场景下显式声明。
总结来说,关联可选属性的本质问题是类型系统缺少对状态组合的表达能力,而Union配合不可变的分支类型恰好补上了这块短板。把“属性可能为None”的思维方式切换成“对象处于哪个状态”,不仅让类型检查更严格,也让代码的业务语义更加清晰。
Python类型检查Union类型类型注解修改时间:2026-09-08 05:42:31