Python 基础体系 · 第 31/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 类型标注基础:Union、Literal、TypedDict、Narrowing 与边界
Python 的类型标注首先是一种静态信息,而不是运行时约束。解释器会保存函数和变量的注解,但不会因为参数标注为 int 就自动拒绝字符串;真正利用这些信息的是类型检查器、IDE、代码分析工具和库作者。(docs.python.org)
本文以 Python 3.14 为范围,围绕五个问题展开:
- 一个值可能属于多种类型时,如何表达?
- 一个值不仅有类型,还有有限的具体取值时,如何表达?
- 外部 JSON、配置、消息等字典结构如何表达?
- 类型检查器如何根据控制流获得更精确的类型?
- 静态类型系统在哪里失效,如何在边界处恢复运行时安全?
一、先建立模型:类型标注描述的是“允许的值集合”
可以把一个类型理解为一个值集合。
例如:
int表示所有整数值;str表示所有字符串值;None表示唯一的None值;Literal["json", "yaml"]表示两个具体字符串值;int | str表示整数集合与字符串集合的并集。
如果把类型 T 看作集合,那么联合类型可以形式化为:
这里:
A是满足类型A的值集合;B是满足类型B的值集合;A | B是两者的并集。
因此,下面的函数意味着调用者必须传入整数或字符串:
def normalize(value: int | str) -> str:
if isinstance(value, int):
return str(value)
return value
在函数体开始处,value 的静态类型是 int | str。执行到 if isinstance(value, int) 的真分支后,类型检查器可以把它缩小为 int;进入 else 后,可以把它缩小为 str。
这就是后文的 narrowing:根据已经执行的条件,从一个较大的类型集合中排除不可能的成员。
二、Union:表达“值属于多个候选类型之一”
2.1 X | Y 是推荐写法
Python 3.10 之后,联合类型推荐使用 |:
def parse_id(value: int | str) -> int:
if isinstance(value, int):
return value
return int(value)
旧写法是:
from typing import Union
def parse_id(value: Union[int, str]) -> int:
...
在 Python 3.14 中,Union[int, str] 与 int | str 创建的是同一种联合类型对象,types.UnionType 也已经是 typing.Union 的别名。代码通常仍然应该使用更直观的 int | str 写法。(docs.python.org)
联合类型中的每个参数必须是类型,并且至少有一个参数。类型系统会消除重复成员,并且联合类型的成员顺序不影响类型相等性:
from typing import Union
assert Union[int, str, int] == int | str
assert Union[int, str] == Union[str, int]
嵌套联合通常会被展平:
assert Union[Union[int, str], float] == int | str | float
但通过类型别名间接引用的联合类型在运行时可能保留别名边界,这是为了避免强制求值类型别名的底层定义。(docs.python.org)
2.2 Union 不是“自动分派”
下面的标注只说明 value 可能是两种类型之一:
def add_one(value: int | str) -> int | str:
return value + 1
这段代码不能通过可靠的静态检查,因为:
- 若
value是int,value + 1合法; - 若
value是str,value + 1不合法。
联合类型不会自动选择运算规则,也不会在运行时根据成员类型调用不同实现。必须显式缩窄,或者改变接口设计:
def add_one(value: int | str) -> int | str:
if isinstance(value, int):
return value + 1
return value + "1"
这里的关键不是“写了 Union”,而是每一条使用路径都必须满足该路径上的成员类型约束。
2.3 Optional[T] 只是 T | None
Optional[T] 等价于 T | None:
from typing import Optional
def find_name(user_id: int) -> str | None:
...
旧写法:
def find_name(user_id: int) -> Optional[str]:
...
Optional 表示“允许 None”,并不表示“参数可以省略”。下面两个函数含义不同:
def read_timeout(timeout: int = 30) -> int:
return timeout
这里参数可以省略,但显式传入的值必须是 int。
def read_timeout(timeout: int | None = None) -> int:
if timeout is None:
return 30
return timeout
这里参数既可以省略,也可以显式传入 None。Python 官方文档明确区分了“有默认值的可选参数”和“允许 None 的参数”。(docs.python.org)
2.4 Union 的主要边界:成员信息可能丢失
考虑一个返回值:
def load_value() -> int | str:
...
调用者只能知道结果是整数或字符串,却不知道某个输入与返回类型之间是否存在关联:
def convert(value: int | str) -> int | str:
...
如果真实规则是:
- 输入
int返回int; - 输入
str返回str;
那么单独使用 Union 会丢失这种对应关系。此时可以使用重载:
from typing import overload
@overload
def convert(value: int) -> int: ...
@overload
def convert(value: str) -> str: ...
def convert(value: int | str) -> int | str:
if isinstance(value, int):
return value + 1
return value.upper()
类型检查器根据调用点选择重载签名:
a = convert(1) # int
b = convert("hello") # str
实现函数仍然需要处理联合类型;重载只改善调用者看到的精确关系,不能替代实现内部的运行时判断。
2.5 运行时检查 Union
Python 3.10 之后,简单联合类型可以用于 isinstance:
def is_scalar(value: object) -> bool:
return isinstance(value, int | str)
不过类型参数化容器不能这样直接检查:
isinstance([], list[int]) # TypeError
运行时只能检查外层容器:
def is_int_list(value: object) -> bool:
return (
isinstance(value, list)
and all(isinstance(item, int) for item in value)
)
原因是 list[int] 的 int 参数主要服务于静态类型系统;普通 list 对象不会携带“所有元素都经过类型检查”的运行时证明。
三、Literal:把“具体值”纳入类型系统
3.1 类型和值之间的区别
str 表示所有字符串,而 Literal["json"] 只表示一个具体值:
from typing import Literal
Format = Literal["json", "yaml", "toml"]
def load_config(path: str, format: Format) -> dict[str, object]:
...
下面的调用可以通过静态检查:
load_config("config.json", "json")
下面的调用应该被类型检查器拒绝:
load_config("config.txt", "xml")
Literal 的参数必须是字面量值,并且至少提供一个参数;它不能被实例化或继承。Python 官方文档将其定义为“值等价于给定字面量之一”的特殊类型形式。(docs.python.org)
3.2 Literal 不是运行时验证器
下面的代码不会自动抛出类型错误:
format: Format = input("format: ")
input() 返回的是普通字符串。用户输入 "xml" 时,Python 解释器不会因为 format 的注解是 Literal["json", "yaml", "toml"] 而阻止赋值。
必须把外部字符串验证为允许的字面量:
from typing import Literal, TypeIs
Format = Literal["json", "yaml", "toml"]
def is_format(value: str) -> TypeIs[Format]:
return value in {"json", "yaml", "toml"}
raw = input("format: ")
if not is_format(raw):
raise ValueError(f"unsupported format: {raw}")
# 这里 raw 被缩窄为 Format
print(f"loading as {raw}")
这个例子有两个层次:
value in {...}是运行时检查;TypeIs[Format]把检查结果告知类型检查器。
如果只写返回类型 bool,类型检查器通常只能知道结果是布尔值,不能据此把 raw 变成 Format。
3.3 Literal 常用于状态、命令和协议字段
典型用途是表示有限状态:
from typing import Literal
Status = Literal["pending", "running", "succeeded", "failed"]
def can_retry(status: Status) -> bool:
return status == "failed"
还可以把 Literal 与 Union 组合成“带判别字段的联合类型”:
from typing import Literal, TypedDict
class Success(TypedDict):
kind: Literal["success"]
value: int
class Failure(TypedDict):
kind: Literal["failure"]
message: str
Result = Success | Failure
调用者可以通过 kind 选择对应结构:
def render(result: Result) -> str:
if result["kind"] == "success":
return f"value={result['value']}"
return f"error={result['message']}"
推导过程如下:
- 初始类型是
Success | Failure; result["kind"] == "success";Success.kind的唯一可能值是"success";Failure.kind的唯一可能值是"failure";- 因此真分支排除
Failure,剩余类型是Success; result["value"]在该分支中合法。
这是 Literal、TypedDict 和 narrowing 的协作,而不是 Literal 单独完成的功能。
3.4 Literal 的边界:运行时相等不等于静态等价
Literal[1] 与 Literal[True] 存在特殊的值相等问题,因为 Python 中:
1 == True # True
但它们在类型设计中通常不应该被混为同一个协议状态。设计判别字段时,最好使用清晰、互斥的字符串字面量:
Literal["success", "failure"]
而不是依赖布尔值与整数之间容易造成误解的相等关系。
此外,Literal 只适合有限、稳定、可以在代码中枚举的值集合。如果允许的值来自数据库、插件或配置文件,使用 Literal 并不会自动让动态数据获得静态保证,仍然需要运行时解析。
四、TypedDict:为字典规定键集合和每个键的值类型
4.1 TypedDict 不是字典子类
TypedDict 描述的是一种字典结构:
from typing import TypedDict
class User(TypedDict):
id: int
name: str
它表示:
- 对象应当是一个字典;
- 键是字符串;
id键对应int;name键对应str;- 默认情况下,声明的键都必须存在。
运行时创建的对象仍然是普通 dict:
user = User(id=1, name="Ada")
print(type(user))
# <class 'dict'>
TypedDict 本身不是一个可以用于 isinstance() 的普通运行时类;它主要供静态类型检查器理解字典的键和值结构。(docs.python.org)
4.2 基本读写规则
from typing import TypedDict
class User(TypedDict):
id: int
name: str
def show_user(user: User) -> str:
return f"{user['id']}: {user['name']}"
以下调用符合声明:
show_user({"id": 1, "name": "Ada"})
以下内容应当产生静态检查错误:
user: User = {
"id": "1", # 错误:应为 int
"name": "Ada",
}
user: User = {
"id": 1,
# 错误:缺少必需的 name
}
user["email"] = "ada@example.com"
# 错误:email 未声明
user["id"] = "1"
# 错误:id 的值必须是 int
TypedDict 约束的是已知键的静态结构。它不是 JSON Schema,也不是数据验证器。一个来自网络的字典仍然可能缺少键、包含错误类型,或者被其他代码在运行时修改。
4.3 NotRequired 与 None 是两个不同概念
可选键使用 NotRequired[T]:
from typing import NotRequired, TypedDict
class UserPatch(TypedDict):
name: NotRequired[str]
age: NotRequired[int]
它表示键可以不存在,但如果存在,值必须符合对应类型。
patch1: UserPatch = {}
patch2: UserPatch = {"name": "Ada"}
这与 str | None 不同:
class UserWithNullableName(TypedDict):
name: str | None
这里 name 仍然是必需键,只是值可以是 None:
good: UserWithNullableName = {"name": None}
bad: UserWithNullableName = {}
# 错误:缺少必需键 name
可以用集合关系区分:
NotRequired[str]:键集合中可以没有"name";str | None:键一定存在,但值集合是字符串与None的并集。
组合写法也可能成立:
class UserPatch(TypedDict):
nickname: NotRequired[str | None]
此时有三种状态:
- 没有
nickname; - 有
nickname,值为字符串; - 有
nickname,值为None。
这三种状态在更新接口中通常具有不同业务含义。
4.4 total=False 与 Required
如果一个结构的大部分键都不是必需的,可以使用 total=False:
from typing import Required, TypedDict
class UserPatch(TypedDict, total=False):
name: str
age: int
user_id: Required[int]
这里:
name和age默认非必需;user_id使用Required[int]覆盖total=False,因此必须存在。
total 只影响当前类体中声明的字段;继承关系和显式的 Required、NotRequired 会共同决定每个字段最终是否必需。(docs.python.org)
4.5 继承与结构化兼容
TypedDict 可以继承另一个 TypedDict:
class User(TypedDict):
id: int
name: str
class AdminUser(User):
permissions: list[str]
AdminUser 包含三个键:
admin: AdminUser = {
"id": 1,
"name": "Ada",
"permissions": ["read", "write"],
}
TypedDict 还具有结构化特征:两个独立声明的 TypedDict,如果键集合、值类型和必需性等结构兼容,通常可以进行静态赋值兼容,而不要求它们共享同一个基类。(typing.python.org)
这与普通类的名义继承不同:
class A(TypedDict):
x: int
class B(TypedDict):
x: int
A 和 B 没有继承关系,但它们描述了相同的字典结构。这个特性使 TypedDict 很适合描述第三方 API、消息协议和 JSON 数据。
4.6 ReadOnly 只约束静态写入
Python 3.13 加入了 ReadOnly,Python 3.14 可以使用:
from typing import ReadOnly, TypedDict
class Event(TypedDict):
event_id: ReadOnly[str]
payload: dict[str, object]
类型检查器应当拒绝:
event["event_id"] = "new-id"
但 ReadOnly 不会冻结底层字典。运行时仍然是普通可变 dict,其他未经过静态检查的代码仍然可以修改它。(docs.python.org)
因此,ReadOnly 是 API 契约,不是运行时不可变性。如果必须在运行时防止修改,应使用不可变对象、封装对象或显式复制。
五、用 TypedDict 表达外部数据时,必须经过运行时边界
类型标注不能证明网络数据的真实性:
import json
from typing import cast
raw = json.loads('{"id": "not-an-int", "name": "Ada"}')
user = cast(User, raw)
print(user["id"] + 1)
cast(User, raw) 不会转换数据,也不会检查字段。程序最终会在 user["id"] + 1 处抛出 TypeError。
cast 的语义是:“告诉类型检查器,相信我,这个值已经是目标类型。”它适用于静态信息丢失但运行时事实已由其他机制保证的场景,不适用于把不可信输入直接变成可信对象。
更可靠的方式是编写运行时验证函数:
from typing import TypedDict, TypeIs
class User(TypedDict):
id: int
name: str
def is_user(value: object) -> TypeIs[User]:
if not isinstance(value, dict):
return False
return (
isinstance(value.get("id"), int)
and isinstance(value.get("name"), str)
)
使用时:
def parse_user(value: object) -> User:
if not is_user(value):
raise ValueError("invalid user payload")
return value
这里的因果链是完整的:
json.loads()的结果应当视为不可信的动态对象;is_user()执行运行时检查;- 检查成功后,
TypeIs[User]为类型检查器提供缩窄信息; - 检查失败时,函数抛出异常;
parse_user()的返回值才可以作为已验证的User使用。
如果数据结构复杂,手写检查可能变得冗长,此时可以使用专门的验证库或 JSON Schema。但无论采用哪种工具,都应区分:
- 外部原始数据;
- 运行时验证后的数据;
- 内部已经满足约束的类型。
六、Narrowing:根据控制流缩小类型集合
6.1 Narrowing 的形式化定义
假设变量 x 的初始类型为:
某个条件 P(x) 能够证明 x 属于类型 R。在真分支中,理论上的新类型是:
在假分支中,理论上的新类型是:
其中:
A是进入条件前已知的类型;R是条件证明的类型;A ∩ R表示同时满足原类型和新条件的值;A ∩ ¬R表示满足原类型但不满足新条件的值。
Python 类型系统不能总是精确表达交集类型和补集类型,因此类型检查器会使用近似规则。类型缩窄规范也明确说明,许多控制流缩窄行为目前仍主要由类型检查器实现决定。(typing.python.org)
6.2 isinstance 是最常见的类型谓词
def describe(value: int | str | None) -> str:
if value is None:
return "none"
if isinstance(value, int):
return f"integer: {value}"
return f"string: {value}"
类型变化如下:
| 位置 | value 的静态类型 |
|---|---|
| 函数入口 | `int |
if value is None 真分支 |
None |
第一个 if 之后 |
`int |
isinstance(value, int) 真分支 |
int |
最后的 return |
str |
isinstance 不只是运行时判断;在静态分析中,它还是一个内置类型谓词。is None、is not None、某些枚举比较和容器成员判断也可能产生缩窄。
6.3 属性和键的缩窄
类层次结构中可以使用 isinstance:
class Animal:
pass
class Dog(Animal):
def bark(self) -> str:
return "woof"
class Cat(Animal):
def meow(self) -> str:
return "meow"
def speak(animal: Dog | Cat) -> str:
if isinstance(animal, Dog):
return animal.bark()
return animal.meow()
对于 TypedDict,判别字段是常见的缩窄方式:
from typing import Literal, TypedDict
class TextMessage(TypedDict):
type: Literal["text"]
text: str
class ImageMessage(TypedDict):
type: Literal["image"]
url: str
Message = TextMessage | ImageMessage
def handle(message: Message) -> str:
if message["type"] == "text":
return message["text"]
return message["url"]
反例:
def broken_handle(message: Message) -> str:
if message["type"] == "text":
return message["url"] # 静态检查错误
return message["text"] # 静态检查错误
判别字段的值必须真正区分各个成员。如果两个 TypedDict 使用相同的判别值:
class A(TypedDict):
kind: Literal["item"]
x: int
class B(TypedDict):
kind: Literal["item"]
y: str
那么:
Value = A | B
检查 value["kind"] == "item" 不能排除任何成员,类型检查器无法安全地决定 x 或 y 哪个字段存在。判别字段必须具有互斥值集合。
6.4 assert 可以缩窄,但不是完整验证
def use_id(value: int | str) -> int:
assert isinstance(value, int)
return value + 1
许多类型检查器会把 assert 之后的 value 视为 int。但 assert 可能被 Python 的优化模式移除,而且错误信息通常不够明确。对于业务边界,更适合显式抛出异常:
def require_int(value: object) -> int:
if not isinstance(value, int):
raise TypeError(f"expected int, got {type(value).__name__}")
return value
assert 适合表达内部不变量;输入验证应使用正常的条件判断和异常处理。
七、TypeGuard 与 TypeIs:自定义类型谓词
7.1 TypeGuard 的基本语义
当内置判断无法表达业务条件时,可以使用 TypeGuard:
from typing import TypeGuard
def is_str_list(value: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(item, str) for item in value)
使用时:
def join_items(value: list[object]) -> str:
if is_str_list(value):
return ", ".join(value)
return "<invalid>"
在真分支中,类型检查器把 value 视为 list[str]。
TypeGuard[T] 表示:
- 函数运行时应返回布尔值;
- 函数返回
True时,第一个参数可以被视为T; - 该类型标记主要服务静态类型检查,不会改变函数的运行时返回值。(docs.python.org)
7.2 为什么 list[object] 可以缩窄为 list[str]
list 是可变容器,通常具有不变性。不能因为 str 是 object 的子类型,就认为:
否则下面的操作会破坏 list[str] 的安全性:
strings: list[str] = ["a"]
objects: list[object] = strings
objects.append(123)
因此,普通赋值关系中,list[str] 不能直接当作 list[object] 使用。
但 TypeGuard 的用途正是表达一种经过运行时检查的特殊事实:这个具体的 list[object] 中所有元素都已经被检查为字符串。类型检查器允许这种缩窄,即使目标类型不是输入类型的子类型。(docs.python.org)
这也说明 TypeGuard 是一个由开发者负责正确性的承诺。如果实现写错:
def is_str_list(value: list[object]) -> TypeGuard[list[str]]:
return True
那么静态分析结果会变得不可靠,运行时仍可能出现非字符串元素。
7.3 TypeGuard 的负分支不会自动排除目标类型
def process(value: list[object]) -> None:
if is_str_list(value):
# value: list[str]
print(" ".join(value))
else:
# value 通常仍是 list[object]
print("not all strings")
TypeGuard 只保证真分支的目标类型。即使函数返回 False,类型检查器也不能一般性地推导出“它一定不是 list[str]”,因为:
- 判断函数可能只检查了部分条件;
- 类型系统可能无法表达完整的补集;
TypeGuard的规范语义只承诺正向缩窄。
因此,如果业务逻辑依赖真假两侧都精确缩窄,应考虑 TypeIs。
7.4 TypeIs 的正反分支语义
TypeIs 是 Python 3.13 加入的类型谓词形式:
from typing import TypeIs
def is_str(value: object) -> TypeIs[str]:
return isinstance(value, str)
对变量:
def use_value(value: str | int) -> str:
if is_str(value):
# value: str
return value.upper()
# value: int
return str(value)
形式化地说,对于:
def predicate(x: I) -> TypeIs[R]:
...
要求 R 可以赋值给 I。若进入真分支:
若进入假分支:
例如:
class Parent:
pass
class Child(Parent):
pass
class Unrelated:
pass
def is_parent(value: object) -> TypeIs[Parent]:
return isinstance(value, Parent)
def run(value: Child | Unrelated) -> None:
if is_parent(value):
# Child 与 Parent 的交集仍是 Child
value # Child
else:
# 排除 Parent 后只剩 Unrelated
value # Unrelated
TypeIs 要求目标类型是输入类型的子类型或可赋值类型,而 TypeGuard 不要求这一点。TypeIs 的真假分支都可以产生缩窄;TypeGuard 主要只保证真分支。(docs.python.org)
7.5 TypeIs 的错误用法
下面的声明应该被类型检查器拒绝:
def is_str(value: int) -> TypeIs[str]:
...
因为 str 不是 int 的子类型,TypeIs 无法在输入类型范围内建立合理的正反分支。
如果确实需要把 list[object] 检查为 list[str],由于 list[str] 不是 list[object] 的子类型,应使用 TypeGuard:
def is_str_list(value: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(item, str) for item in value)
选择规则可以概括为:
- 输入和目标类型存在正常子类型关系,并且真假两侧都应缩窄:使用
TypeIs; - 需要表达非子类型关系,例如可变容器经过内容验证后的类型:使用
TypeGuard; - 无法证明谓词实现绝对正确时,不要为了消除类型检查错误而随意添加二者。
八、类型缩窄的真实边界
8.1 缩窄通常只在当前控制流路径有效
def process(value: str | None) -> None:
if value is None:
return
print(value.upper())
在 return 之后的路径中,value 已经排除了 None。
但如果值可能被别的代码修改,类型检查器不会自动建模所有别名和并发行为:
def process(value: str | None) -> None:
if value is not None:
use_value(value)
print(value.upper())
如果 use_value() 通过共享状态修改了某个外部对象,类型检查器通常不会因此撤销当前局部变量的缩窄。静态分析基于可见控制流和有限的别名分析,不是完整的程序验证器。
8.2 可变对象会让“检查后的事实”过期
from typing import Any
data: dict[str, Any] = {"name": "Ada"}
if isinstance(data.get("name"), str):
# 此时检查成立
name = data["name"]
data["name"] = 42
把检查后的值复制到局部变量通常更稳妥:
raw_name = data.get("name")
if not isinstance(raw_name, str):
raise ValueError("name must be a string")
name = raw_name
这里 name 是一个独立的字符串引用;后续字典被修改不会改变它的类型和内容。
8.3 Any 会关闭大量静态检查
from typing import Any
value: Any = "hello"
result = value.not_existing_method()
Any 的核心语义是允许它与多数类型互相赋值,并允许任意操作。它适合表示确实无法静态知道的值,但会形成类型系统的逃逸点。
对比:
value: object = "hello"
value.not_existing_method()
# 类型检查错误:object 没有该属性
object 表示“某个对象,但未知具体类型”;Any 表示“暂时关闭对这个值的静态约束”。接收外部输入时,通常先使用 object,再通过 isinstance、TypedDict 验证函数或解析器建立更精确类型。
8.4 cast 不产生运行时行为
from typing import cast
value: object = "hello"
text = cast(str, value)
cast 不复制、不转换、不检查:
assert text is value
错误使用示例:
value: object = 123
text = cast(str, value)
print(text.upper()) # AttributeError
cast 合理的使用场景是:运行时保证来自函数设计、协议约束或之前的验证,但类型检查器无法推导出来。例如某个注册表在构造阶段已完成完整初始化,读取时类型信息丢失,可以用更精确的封装函数恢复类型。它不应被用作输入校验的替代品。
8.5 # type: ignore 会掩盖真实错误
result = value + other # type: ignore
这种写法可能隐藏:
- 类型声明错误;
- 实现逻辑错误;
- 第三方库存根错误;
- 实际需要运行时判断的边界。
更好的诊断顺序是:
- 先确认联合类型的每个成员是否都处理;
- 确认
None是否被显式排除; - 确认 TypedDict 键是必需还是
NotRequired; - 确认类型检查器是否识别当前缩窄模式;
- 如果是外部数据,补上运行时验证;
- 最后才考虑局部、带原因的忽略。
九、TypedDict 联合与接口设计
9.1 不要把互斥结构强行压成一个大 TypedDict
下面的写法把两个互斥结构混在一起:
class BadMessage(TypedDict, total=False):
type: str
text: str
url: str
它允许这些不完整状态:
{}
{"type": "text"}
{"type": "image", "text": "unexpected"}
{"type": "unknown", "url": "x"}
如果业务规则是“文本消息必须有 text,图片消息必须有 url”,应该建模为判别联合:
class TextMessage(TypedDict):
type: Literal["text"]
text: str
class ImageMessage(TypedDict):
type: Literal["image"]
url: str
Message = TextMessage | ImageMessage
此时类型系统可以帮助检查每个状态的字段完整性。
9.2 如果状态之间有关联,Union 可能不够精确
考虑:
def request(mode: Literal["sync", "async"]) -> str | object:
...
调用者无法从返回类型知道:
mode == "sync"时返回什么;mode == "async"时返回什么。
可以用重载保留输入与输出的关联:
from typing import Literal, overload
@overload
def request(mode: Literal["sync"]) -> str: ...
@overload
def request(mode: Literal["async"]) -> object: ...
def request(mode: Literal["sync", "async"]) -> str | object:
if mode == "sync":
return "done"
return object()
这里 Literal 负责区分输入状态,overload 负责表达输入与输出之间的函数关系,Union 负责实现函数体内部的可能返回值。
9.3 什么时候应该改用类或 Protocol
TypedDict 适合“数据记录”,尤其是:
- JSON 负载;
- 配置项;
- 数据库查询结果;
- 事件消息;
- 参数字典。
如果对象需要:
- 不变量;
- 方法;
- 生命周期;
- 封装状态;
- 运行时校验;
- 多态行为;
则普通类、ABC 或 Protocol 通常更合适。
例如,下面的 TypedDict 只描述数据:
class Payment(TypedDict):
amount: int
currency: Literal["CNY", "USD"]
如果需要保证金额非负、货币转换和审计行为,可以改为类:
class Payment:
def __init__(self, amount: int, currency: str) -> None:
if amount < 0:
raise ValueError("amount must be non-negative")
if currency not in {"CNY", "USD"}:
raise ValueError("unsupported currency")
self.amount = amount
self.currency = currency
类型标注可以表达结构,但不能替代所有运行时不变量。
十、可运行示例:从 JSON 到类型安全处理
下面的完整示例组合使用:
TypedDict描述消息结构;Literal描述判别字段;Union表达多种消息;TypeIs执行自定义缩窄;- 运行时检查处理不可信输入。
from __future__ import annotations
import json
from typing import Literal, TypedDict, TypeIs
class TextMessage(TypedDict):
type: Literal["text"]
text: str
class ImageMessage(TypedDict):
type: Literal["image"]
url: str
Message = TextMessage | ImageMessage
def is_message(value: object) -> TypeIs[Message]:
if not isinstance(value, dict):
return False
message_type = value.get("type")
if message_type == "text":
return isinstance(value.get("text"), str)
if message_type == "image":
return isinstance(value.get("url"), str)
return False
def render(message: Message) -> str:
if message["type"] == "text":
return message["text"]
return f"[image: {message['url']}]"
def parse_message(raw_json: str) -> Message:
value: object = json.loads(raw_json)
if not is_message(value):
raise ValueError("invalid message payload")
return value
if __name__ == "__main__":
text = parse_message('{"type": "text", "text": "hello"}')
image = parse_message('{"type": "image", "url": "https://example.test/a.png"}')
print(render(text))
print(render(image))
预期输出:
hello
[image: https://example.test/a.png]
处理流程是:
json.loads()返回动态数据,不能直接假设其结构;value被声明为object,避免过早引入Any;is_message()检查外层是否为字典;- 根据
type检查对应字段; - 检查成功后,
TypeIs[Message]使value缩窄为TextMessage | ImageMessage; render()再根据判别字段缩窄到具体消息类型;- 最终访问的字段与消息状态一致。
错误输入会在解析边界被拒绝:
parse_message('{"type": "text", "url": "wrong-field"}')
# ValueError: invalid message payload
parse_message('{"type": "image", "url": 123}')
# ValueError: invalid message payload
这比下面的写法安全:
from typing import cast
def unsafe_parse(raw_json: str) -> Message:
return cast(Message, json.loads(raw_json))
后者只是让类型检查器停止报告错误,并没有证明输入满足 Message 结构。
十一、Python 3.14 范围内的版本注意事项
11.1 推荐使用内置泛型和 |
Python 3.14 代码通常可以写:
type UserId = int
type Names = list[str]
type Result = int | str
而不是:
from typing import List, Union
UserId = int
Names = List[str]
Result = Union[int, str]
type 类型别名语法自 Python 3.12 引入;旧的 TypeAlias 形式已被标记为弃用方向,官方文档建议迁移到 type 语句。(docs.python.org)
11.2 TypeIs 与 ReadOnly 不是旧版本语法
TypeGuard:Python 3.10;TypedDict:Python 3.8;Required、NotRequired:Python 3.11;ReadOnly:Python 3.13;TypeIs:Python 3.13。(docs.python.org)
如果库需要支持更旧版本,应从 typing_extensions 使用相应回移植定义;如果项目明确只支持 Python 3.14,则可以直接从 typing 导入。
十二、如何判断一个类型标注是否建模正确
可以按照以下推导检查,而不是从语法表面判断。
第一步:列出所有运行时状态
例如消息系统可能有:
文本消息:type=text,必须有 text
图片消息:type=image,必须有 url
第二步:决定状态差异是“类型差异”还是“值差异”
int | str:不同 Python 类型;Literal["text", "image"]:同一基础类型中的有限值;TextMessage | ImageMessage:不同结构的联合。
第三步:检查每个状态是否能被唯一识别
如果一个字段的取值不能唯一对应某个结构,类型检查器就无法安全缩窄。
第四步:区分“键不存在”和“值为 None”
- 键可能不存在:
NotRequired[T]; - 键一定存在但可以为空:
T | None。
第五步:检查外部数据是否经过运行时验证
来自网络、文件、环境变量、用户输入和 json.loads() 的数据都不能因为写了注解就自动可信。
第六步:检查自定义谓词是否真的满足承诺
对于:
def predicate(value: I) -> TypeIs[R]:
...
必须确保:
- 返回值确实是
bool; - 返回
True时,值确实满足R; - 返回
False时,值确实排除了R; R与I具有合法的赋值关系。
对于 TypeGuard,至少必须保证真分支的目标类型确实成立。
结语:类型系统描述边界,运行时逻辑证明边界
Union 描述“多个类型中的一个”,Literal 描述“有限的具体值”,TypedDict 描述“字典的键和值结构”,narrowing 则把这些宽泛描述沿控制流逐步缩小。
它们共同形成如下模型:
外部动态数据
│
├─ 运行时检查
│
▼
TypedDict / Literal / Union
│
├─ isinstance、is None、判别字段
├─ TypeGuard / TypeIs
│
▼
当前分支中的精确类型
静态类型检查器可以推导代码中已经表达出来的事实,但不会替程序验证网络输入,不会执行 cast,不会冻结 TypedDict,也不会自动修复错误的 TypeGuard。
因此,类型标注的正确边界是:
- 用类型表达允许的内部状态;
- 用 Literal 表达有限且互斥的协议值;
- 用 TypedDict 表达数据记录,而不是运行时对象不变量;
- 用 narrowing 让控制流与类型信息同步;
- 用运行时验证把不可信输入转换为可信内部状态;
- 对
Any、cast、TypeGuard和忽略指令保持明确的责任意识。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 反射与自省:inspect、签名、Frame、AST 和安全边界
- 下一篇:Python 泛型类型:TypeVar、ParamSpec、TypeVarTuple 与 Protocol
- 延伸:Python ABC 与 Protocol:名义子类型、结构化类型和接口设计
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论