Python TypedDict 实战:给一坨 dict 上类型,让 mypy 抓住拼错的 key 和缺字段后端代码里到处是 dict:接口返回的 JSON、配置、缓存里的记录。问题是 dict 太自由了——你写user[nmae](把 name 拼错成 nmae),IDE 不报错,mypy 不报错,一路跑到线上KeyError才炸。你想用dataclass收敛它,但数据本来就是从json.loads出来的普通 dict,硬转成对象既麻烦又多一层。TypedDict就是为这个场景生的:它让一个普通 dict 拥有固定的 key 和每个 key 的类型,静态检查能抓错,运行时它还是个 dict、零开销。从「裸 dict」到 TypedDict先看没类型时的痛:defgreet(user:dict)-str:# user 里到底有啥字段?IDE 一无所知returnfHi{user[name]}, age{user[aeg]}# aeg 拼错了,没人拦得住greet({name:Ann,age:30})# 运行到这一行才 KeyError: aeg用TypedDict声明结构后,mypy 直接在提交前就抓住:fromtypingimportTypedDictclassUser(TypedDict):name:strage:intdefgreet(user:User)-str:returnfHi{user[name]}, age{user[aeg]}# mypy 报错:TypedDict User has no key aegu:User{name:Ann,age:30}greet(u)跑mypy your_file.py,它会直接指出aeg不是合法 key,还会检查age必须是int——你写成30也会被逮到。而运行时,u就是个普普通通的 dict,isinstance(u, dict)为True,没有任何额外的类,序列化回 JSON 也不用改一行。两种声明语法,函数式适合 key 不是合法标识符时除了上面的 class 写法,还有函数式写法。它在「key 带空格、带连字符、是保留字」时是唯一选择:fromtypingimportTypedDict# key 叫 content-type 带连字符,class 语法写不出来,只能用函数式HeadersTypedDict(Headers,{content-type:str,x-request-id:str,})h:Headers{content-type:application/json,x-request-id:abc123}日常字段名规规矩矩就用 class 写法,可读性更好;遇到 HTTP header 这种带杠的 key,记得函数式语法能救场。required 与 optional:totalFalse 与逐字段控制默认情况下TypedDict的每个 key 都是必填的,少一个 mypy 就报错。但真实 JSON 常有可选字段。控制方式有两层。整个 dict 都可选,用totalFalse:classQuery(TypedDict,totalFalse):page:intsize:intkeyword:strq:Query{page:1}# OK,其余可缺省q2:Query{}# 也 OK更常见的是「部分必填、部分可选」。Python 3.11 用Required/NotRequired精确到每个字段,不用为此拆成两个 TypedDict:fromtypingimportTypedDict,NotRequired# 3.11;更早版本从 typing_extensions 导入classArticle(TypedDict):title:str# 必填author:str# 必填tags:NotRequired[list[str]]# 可选,可以整个不出现a1:Article{title:T,author:Me}# OK,tags 缺省a2:Article{title:T,author:Me,tags:[py]}# OKa3:Article{title:T}# mypy 报错:缺少必填 key author易错点:NotRequired表示「这个 key 可以不存在」,不等于「值可以是 None」。如果字段会出现但值可能为空,那是str | None,两者含义不同——一个是 key 缺席,一个是 key 在但值为空,别混:classProfile(TypedDict):bio:NotRequired[str]# bio 这个 key 可以整个不存在avatar:str|None# avatar 这个 key 一定在,但值可能是 None实战:给 API 返回的 JSON 上类型最能体现价值的场景——json.loads出来的数据本身是dict[str, Any],类型全丢了。用cast给它「贴上」TypedDict,后续访问就有完整补全和检查:importjsonfromtypingimportTypedDict,castclassOrderResp(TypedDict):order_id:stramount:int# 单位:分,别用 float 算钱paid:booldefparse_order(raw:str)-OrderResp:datajson.loads(raw)# 这里是 dict[str, Any],无类型returncast(OrderResp,data)# 贴上类型,告诉 mypy「我保证它长这样」respparse_order({order_id:A1,amount:1999,paid:true})print(resp[amount]/100)# mypy 知道 amount 是 int,补全也有了print(resp[amuont])# mypy 报错:拼错的 key 被逮到这里必须强调cast的边界:cast只是「骗」类型检查器,它不做任何运行时校验。如果后端实际返回的字段和你声明的对不上(比如少了paid),cast不会报错,运行时照样KeyError。也就是说TypedDict保证的是「你自己的代码怎么用这个 dict」的静态正确性,不保证外部数据真的符合结构。要在运行时验证外部数据(接口、用户输入),该上pydantic,那才是做运行时校验的工具。两者分工:内部数据流转、想零开销加静态检查用TypedDict;系统边界做真校验用pydantic。访问不存在的可选 key:用 .get 而不是下标对NotRequired或totalFalse的字段,直接用q[keyword]下标访问,mypy 可能不拦但运行时字段不在就KeyError。安全的做法是.get,mypy 也认:kwq.get(keyword,)# 不存在返回默认值 sizeq.get(size)# 返回 int | None,mypy 会提醒你处理 None小结TypedDict给普通 dict 声明固定 key 和字段类型,mypy 能在上线前抓住拼错的 key、缺失的必填字段、类型不符,而运行时它就是个零开销的 dict。字段名带杠/是保留字时用函数式语法TypedDict(X, {...});否则用 class 写法。可选字段:整体用totalFalse,精确到字段用NotRequired/Required;注意「key 可缺席」≠「值可为 None」。cast只骗静态检查、不做运行时校验;要验证外部数据的真实结构,用pydantic。访问可选字段用.get,别用下标硬取。一句话记忆:TypedDict 是「给 dict 发身份证」——编译期认字段、运行期还是 dict;但它只查你的代码,不查外面进来的数据。