Python 基础体系 · 第 31/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 类型标注基础:Union、Literal、TypedDict、Narrowing 与边界

Python 的类型标注首先是一种静态信息,而不是运行时约束。解释器会保存函数和变量的注解,但不会因为参数标注为 int 就自动拒绝字符串;真正利用这些信息的是类型检查器、IDE、代码分析工具和库作者。(docs.python.org)

本文以 Python 3.14 为范围,围绕五个问题展开:

  1. 一个值可能属于多种类型时,如何表达?
  2. 一个值不仅有类型,还有有限的具体取值时,如何表达?
  3. 外部 JSON、配置、消息等字典结构如何表达?
  4. 类型检查器如何根据控制流获得更精确的类型?
  5. 静态类型系统在哪里失效,如何在边界处恢复运行时安全?

一、先建立模型:类型标注描述的是“允许的值集合”

可以把一个类型理解为一个值集合。

例如:

  • int 表示所有整数值;
  • str 表示所有字符串值;
  • None 表示唯一的 None 值;
  • Literal["json", "yaml"] 表示两个具体字符串值;
  • int | str 表示整数集合与字符串集合的并集。

如果把类型 T 看作集合,那么联合类型可以形式化为:

AB=ABA \mid B = A \cup B

这里:

  • 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

这段代码不能通过可靠的静态检查,因为:

  • valueintvalue + 1 合法;
  • valuestrvalue + 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}")

这个例子有两个层次:

  1. value in {...} 是运行时检查;
  2. 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']}"

推导过程如下:

  1. 初始类型是 Success | Failure
  2. result["kind"] == "success"
  3. Success.kind 的唯一可能值是 "success"
  4. Failure.kind 的唯一可能值是 "failure"
  5. 因此真分支排除 Failure,剩余类型是 Success
  6. 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 NotRequiredNone 是两个不同概念

可选键使用 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]

此时有三种状态:

  1. 没有 nickname
  2. nickname,值为字符串;
  3. nickname,值为 None

这三种状态在更新接口中通常具有不同业务含义。


4.4 total=FalseRequired

如果一个结构的大部分键都不是必需的,可以使用 total=False

from typing import Required, TypedDict

class UserPatch(TypedDict, total=False):
    name: str
    age: int
    user_id: Required[int]

这里:

  • nameage 默认非必需;
  • user_id 使用 Required[int] 覆盖 total=False,因此必须存在。

total 只影响当前类体中声明的字段;继承关系和显式的 RequiredNotRequired 会共同决定每个字段最终是否必需。(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

AB 没有继承关系,但它们描述了相同的字典结构。这个特性使 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

这里的因果链是完整的:

  1. json.loads() 的结果应当视为不可信的动态对象;
  2. is_user() 执行运行时检查;
  3. 检查成功后,TypeIs[User] 为类型检查器提供缩窄信息;
  4. 检查失败时,函数抛出异常;
  5. parse_user() 的返回值才可以作为已验证的 User 使用。

如果数据结构复杂,手写检查可能变得冗长,此时可以使用专门的验证库或 JSON Schema。但无论采用哪种工具,都应区分:

  • 外部原始数据;
  • 运行时验证后的数据;
  • 内部已经满足约束的类型。

六、Narrowing:根据控制流缩小类型集合

6.1 Narrowing 的形式化定义

假设变量 x 的初始类型为:

A=T1T2TnA = T_1 \cup T_2 \cup \dots \cup T_n

某个条件 P(x) 能够证明 x 属于类型 R。在真分支中,理论上的新类型是:

ARA \cap R

在假分支中,理论上的新类型是:

A¬RA \cap \neg 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 Noneis 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" 不能排除任何成员,类型检查器无法安全地决定 xy 哪个字段存在。判别字段必须具有互斥值集合。


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] 表示:

  1. 函数运行时应返回布尔值;
  2. 函数返回 True 时,第一个参数可以被视为 T
  3. 该类型标记主要服务静态类型检查,不会改变函数的运行时返回值。(docs.python.org)

7.2 为什么 list[object] 可以缩窄为 list[str]

list 是可变容器,通常具有不变性。不能因为 strobject 的子类型,就认为:

list[str]list[object]list[str] \subseteq list[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。若进入真分支:

x:ARx : A \cap R

若进入假分支:

x:A¬Rx : A \cap \neg R

例如:

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

这种写法可能隐藏:

  • 类型声明错误;
  • 实现逻辑错误;
  • 第三方库存根错误;
  • 实际需要运行时判断的边界。

更好的诊断顺序是:

  1. 先确认联合类型的每个成员是否都处理;
  2. 确认 None 是否被显式排除;
  3. 确认 TypedDict 键是必需还是 NotRequired
  4. 确认类型检查器是否识别当前缩窄模式;
  5. 如果是外部数据,补上运行时验证;
  6. 最后才考虑局部、带原因的忽略。

九、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]

处理流程是:

  1. json.loads() 返回动态数据,不能直接假设其结构;
  2. value 被声明为 object,避免过早引入 Any
  3. is_message() 检查外层是否为字典;
  4. 根据 type 检查对应字段;
  5. 检查成功后,TypeIs[Message] 使 value 缩窄为 TextMessage | ImageMessage
  6. render() 再根据判别字段缩窄到具体消息类型;
  7. 最终访问的字段与消息状态一致。

错误输入会在解析边界被拒绝:

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 TypeIsReadOnly 不是旧版本语法

  • TypeGuard:Python 3.10;
  • TypedDict:Python 3.8;
  • RequiredNotRequired: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
  • RI 具有合法的赋值关系。

对于 TypeGuard,至少必须保证真分支的目标类型确实成立。


结语:类型系统描述边界,运行时逻辑证明边界

Union 描述“多个类型中的一个”,Literal 描述“有限的具体值”,TypedDict 描述“字典的键和值结构”,narrowing 则把这些宽泛描述沿控制流逐步缩小。

它们共同形成如下模型:

外部动态数据
    │
    ├─ 运行时检查
    │
    ▼
TypedDict / Literal / Union
    │
    ├─ isinstance、is None、判别字段
    ├─ TypeGuard / TypeIs
    │
    ▼
当前分支中的精确类型

静态类型检查器可以推导代码中已经表达出来的事实,但不会替程序验证网络输入,不会执行 cast,不会冻结 TypedDict,也不会自动修复错误的 TypeGuard

因此,类型标注的正确边界是:

  • 用类型表达允许的内部状态;
  • 用 Literal 表达有限且互斥的协议值;
  • 用 TypedDict 表达数据记录,而不是运行时对象不变量;
  • 用 narrowing 让控制流与类型信息同步;
  • 用运行时验证把不可信输入转换为可信内部状态;
  • AnycastTypeGuard 和忽略指令保持明确的责任意识。

系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。