Python 类型注解:从 TypeScript 迁移理解

0 阅读11分钟

这篇主要对标 TypeScript:变量类型、函数类型、联合类型、字面量类型、interface、泛型、数据模型。

先记住一句话:TypeScript 是给 JavaScript 加静态类型;Python 类型注解是给 Python 代码加“类型说明”。它主要服务编辑器、类型检查工具和框架,不会默认在运行时帮你拦截错误。

一、先看一个完整例子

假设你在前端写一个订单摘要:

type UserRole = "admin" | "member" | "guest";

interface User {
  id: number;
  name: string;
  role: UserRole;
}

interface CartItem {
  productId: string;
  title: string;
  price: number;
  count: number;
}

interface OrderSummary {
  userId: number;
  userName: string;
  total: number;
  itemTitles: string[];
}

function createOrderSummary(user: User, cartItems: CartItem[]): OrderSummary {
  const total = cartItems.reduce((sum, item) => {
    return sum + item.price * item.count;
  }, 0);

  return {
    userId: user.id,
    userName: user.name,
    total,
    itemTitles: cartItems.map((item) => item.title),
  };
}

Python 里可以这样写:

from typing import Literal, TypedDict

UserRole = Literal["admin", "member", "guest"] # 类似 TS 的字面量联合类型


class User(TypedDict): # 类似 TS interface,用来描述 dict 的结构
    id: int
    name: str
    role: UserRole


class CartItem(TypedDict):
    product_id: str
    title: str
    price: float
    count: int


class OrderSummary(TypedDict):
    user_id: int
    user_name: str
    total: float
    item_titles: list[str]


def create_order_summary(user: User, cart_items: list[CartItem]) -> OrderSummary:
    total = 0.0

    for item in cart_items:
        total += item["price"] * item["count"]

    return {
        "user_id": user["id"],
        "user_name": user["name"],
        "total": total,
        "item_titles": [item["title"] for item in cart_items],
    }

从这个例子能先看到几个核心映射:

TypeScriptPython
numberint / float
stringstr
booleanbool
nullNone
string[]list[str]
Record<string, number>dict[str, int]
"admin" | "user"Literal["admin", "user"]
interface User {}class User(TypedDict):
(value: number) => stringCallable[[int], str]

二、Python 类型注解和 TS 最大的区别

TS 类型会参与编译检查

TypeScript 本身就是一个静态类型系统。你写错类型,tsc 会报错。

const age: number = "18"; // 报错,string 不能赋值给 number

Python 类型注解默认不拦截运行

Python 是动态语言,类型注解默认只是说明。

age: int = "18" # 代码能运行,但类型检查工具会提示不对
print(age) # 输出 "18"

也就是说,Python 类型注解主要有三个用途:

  • 给自己和别人读代码时看清楚数据结构
  • 给编辑器提供自动补全、跳转、错误提示
  • pyright / mypy 这类工具做静态检查

如果你希望运行时也校验数据,通常要配合 Pydanticdataclass、表单校验、接口参数校验等工具。

三、变量类型标注

Python 变量类型写在变量名后面。

name: str = "Alice"
age: int = 18
price: float = 99.8
is_active: bool = True
empty_value: None = None

前端类比:

const name: string = "Alice";
const age: number = 18;
const price: number = 99.8;
const isActive: boolean = true;
const emptyValue: null = null;

什么时候需要给变量写类型

简单变量可以不写,因为 Python 类型检查工具能推断:

name = "Alice" # 工具能推断 name 是 str
age = 18 # 工具能推断 age 是 int

但是这几种情况建议写:

  • 空列表、空字典刚创建时
  • 函数返回值接收后不容易看出类型时
  • 业务模型字段比较复杂时
  • 你希望编辑器后续自动补全时
user_ids: list[int] = []
user_score_map: dict[int, float] = {}

四、函数参数和返回值

Python 函数参数类型写在参数后面,返回值类型写在 -> 后面。

def add(a: int, b: int) -> int:
    return a + b


result: int = add(1, 2)

前端类比:

function add(a: number, b: number): number {
  return a + b;
}

没有返回值用 None

Python 函数没有写 return 时,真实返回值是 None。这点类似 JS 函数没有返回值时是 undefined

def log_message(message: str) -> None:
    print(message)
function logMessage(message: string): void {
  console.log(message);
}

在 Python 里,-> None 更像 TS 的 void:告诉别人这个函数不是为了返回数据。

五、容器类型

Python 3.9+ 可以直接写 list[str]dict[str, int] 这类类型。

names: list[str] = ["Alice", "Bob"]
scores: list[int] = [90, 95, 100]
user: dict[str, str | int] = {"id": 1, "name": "Alice"}
point: tuple[int, int] = (10, 20)
tags: set[str] = {"python", "backend"}

前端类比:

const names: string[] = ["Alice", "Bob"];
const scores: number[] = [90, 95, 100];
const user: Record<string, string | number> = { id: 1, name: "Alice" };
const point: readonly [number, number] = [10, 20];
const tags: Set<string> = new Set(["python", "backend"]);

常用对照

场景TypeScriptPython
字符串数组string[]list[str]
数字数组number[]list[int] / list[float]
对象字典Record<string, string>dict[str, str]
固定长度元组[number, string]tuple[int, str]
集合Set<string>set[str]

六、联合类型和可选类型

Python 3.10+ 可以用 | 表示联合类型。

def format_user_id(user_id: int | str) -> str:
    return str(user_id)

前端类比:

function formatUserId(userId: number | string): string {
  return String(userId);
}

可选值就是和 None 联合

Python 没有 JS 里的 undefined。业务里表达“可能没有值”,最常见就是 None

def find_user_name(user_id: int) -> str | None:
    if user_id == 1:
        return "Alice"

    return None

前端类比:

function findUserName(userId: number): string | null {
  if (userId === 1) {
    return "Alice";
  }

  return null;
}

以前的写法也会看到:

from typing import Optional

name: Optional[str] = None # 等价于 str | None

现在更推荐直接写 str | None,更接近 TS 的联合类型写法。

七、Literal:字面量类型

TS 里经常用字符串字面量限制枚举值:

type VisitStatus = "pending" | "approved" | "rejected";

Python 里用 Literal

from typing import Literal

VisitStatus = Literal["pending", "approved", "rejected"]


def get_status_text(status: VisitStatus) -> str:
    if status == "pending":
        return "待审核"
    if status == "approved":
        return "已通过"
    return "已拒绝"

适合用 Literal 的场景:

  • 状态值固定,比如 "pending" / "approved"
  • 类型值固定,比如 "person" / "car"
  • 请求参数只能传几个固定字符串
  • 函数 mode 参数只能传几个固定选项

如果值很多,或者需要行为和属性,通常改用 Enum

from enum import Enum


class VisitStatusEnum(str, Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"

八、TypedDict:对标 interface

如果你的数据本质是字典,比如接口返回、JSON 数据、配置对象,可以用 TypedDict 描述结构。

from typing import TypedDict


class UserPayload(TypedDict):
    id: int
    name: str
    age: int
    email: str | None


def normalize_user(user: UserPayload) -> str:
    return f"{user['name']}({user['id']})"

前端类比:

interface UserPayload {
  id: number;
  name: string;
  age: number;
  email: string | null;
}

可选字段

TS 里写 email?: string,表示字段可以不存在。

interface UserPayload {
  id: number;
  name: string;
  email?: string;
}

Python 3.11+ 可以用 NotRequired

from typing import NotRequired, TypedDict


class UserPayload(TypedDict):
    id: int
    name: str
    email: NotRequired[str]

这里要分清楚两个概念:

email: str | None # 字段必须存在,但值可以是 None
email: NotRequired[str] # 字段可以不存在;如果存在,值必须是 str

前端里也一样:

email: string | null; // 字段存在,值可能是 null
email?: string; // 字段可能不存在

九、dataclass:对标数据类,不等于 interface

如果你不想一直用字典访问,也可以定义数据对象。

from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str
    age: int


user = User(id=1, name="Alice", age=18)
print(user.name)

它有点像 TS 里定义一个 class:

class User {
  constructor(
    public id: number,
    public name: string,
    public age: number,
  ) {}
}

dataclass 会自动生成构造函数、打印格式、比较方法等,适合纯数据对象。

但要注意:dataclass 默认也不会校验运行时类型。

user = User(id="1", name="Alice", age="18") # 仍然能创建

所以它更像“带类型说明的数据结构”,不是运行时表单校验器。

十、Pydantic:更接近运行时 schema

如果你写 FastAPI,或者要校验接口入参,Pydantic 很常见。

from pydantic import BaseModel

class CreateUserRequest(BaseModel):
    name: str
    age: int
    email: str | None = None

payload = CreateUserRequest(name="Alice", age="18")
print(payload.age) # 18,Pydantic 会尝试转换类型

它和 TS 的区别要分清楚:

能力TypeScript interfacePython TypedDictPython dataclassPydantic BaseModel
描述字段结构可以可以可以可以
运行时校验不可以不可以默认不可以可以
自动类型转换不可以不可以不可以可以
适合接口入参静态开发期静态开发期内部数据对象很适合

如果按前端理解,Pydantic 更像:

TypeScript 类型 + zod/yup 表单校验 + DTO

所以 Python 后端项目里经常会看到:

类型注解
  -> 描述代码里变量和函数的类型

Pydantic
  -> 描述接口入参、出参,并做运行时校验

十一、Callable:函数类型

TS 里函数也可以作为参数:

function apply(value: number, handler: (value: number) => string): string {
  return handler(value);
}

Python 里用 Callable

from collections.abc import Callable


def apply(value: int, handler: Callable[[int], str]) -> str:
    return handler(value)


def format_price(price: int) -> str:
    return f"{price} 元"


text = apply(100, format_price)

Callable[[int], str] 的意思是:

接收一个 int 参数
返回 str

如果函数没有参数:

from collections.abc import Callable

def run_task(task: Callable[[], None]) -> None:
    task()

如果参数不固定,临时可以写:

from collections.abc import Callable
from typing import Any

handler: Callable[..., Any]

但这个写法信息量很少,类似 TS 里写 (...args: any[]) => any,能不用就少用。

十二、泛型

泛型的作用是:把输入类型和输出类型关联起来。

TS 写法:

function first<T>(items: T[]): T {
  return items[0];
}

const name = first(["Alice", "Bob"]); // string
const age = first([18, 20]); // number

Python 写法:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

name = first(["Alice", "Bob"]) # 推断为 str
age = first([18, 20]) # 推断为 int

如果不用泛型,写成这样就丢失了关联:

from typing import Any

def first(items: list[Any]) -> Any:
    return items[0]

这就类似 TS 里:

function first(items: any[]): any {
  return items[0];
}

返回值变成 Any 后,编辑器就很难继续提示正确的方法。

十三、类型收窄

TS 会根据判断自动缩小类型范围:

function formatValue(value: string | number): string {
  if (typeof value === "number") {
    return value.toFixed(2);
  }

  return value.trim();
}

Python 里常用 isinstance 做类型收窄:

def format_value(value: str | int | float) -> str:
    if isinstance(value, int | float):
        return f"{value:.2f}"

    return value.strip()

None 也经常需要先判断:

def get_name_length(name: str | None) -> int:
    if name is None:
        return 0

    return len(name)

这个习惯很重要。你看到 str | None,就应该想到:使用前要处理 None 分支。

十四、Any 和 unknown 的区别

Python 的 Any 很像 TS 的 any

from typing import Any

def print_value(value: Any) -> None:
    print(value)

Any 的问题是:它会绕过类型检查。

from typing import Any

def get_user() -> Any:
    return {"id": 1, "name": "Alice"}

user = get_user()
user.not_exists() # 类型检查工具可能不会拦你

在 TS 里,如果你想表达“不确定类型,但使用前必须检查”,会用 unknown。Python 标准类型里没有完全等价的 unknown,常见替代思路是:

  • 能写清楚结构就别写 Any
  • 外部 JSON 先用 dict[str, Any] 接住,再转换成明确模型
  • 接口边界用 Pydantic 校验成明确类型
from typing import Any

raw_user: dict[str, Any] = {"id": 1, "name": "Alice"}

user: UserPayload = {
    "id": int(raw_user["id"]),
    "name": str(raw_user["name"]),
    "age": 18,
    "email": None,
}

十五、常见坑

坑一:类型注解不是运行时校验

def add(a: int, b: int) -> int:
    return a + b


print(add("1", "2")) # 输出 "12",不会因为标注了 int 就自动报错

想发现这种问题,需要跑类型检查:

uv add --dev pyright
uv run pyright

或者:

uv add --dev mypy
uv run mypy .

坑二:空列表最好主动标注

user_ids = [] # 工具不好判断里面以后放什么
user_ids.append(1)

更清楚:

user_ids: list[int] = []
user_ids.append(1)

坑三:dict 的 key 要写清楚

user: dict[str, str] = {
    "name": "Alice",
    "city": "Shanghai",
}

如果 value 类型不统一,就用联合类型或 TypedDict

user1: dict[str, str | int] = {
    "id": 1,
    "name": "Alice",
}

更推荐:

class UserInfo(TypedDict):
    id: int
    name: str


user2: UserInfo = {
    "id": 1,
    "name": "Alice",
}

坑四:不要过早把所有东西写成 Any

from typing import Any

def handle_user(user: dict[str, Any]) -> None:
    print(user["name"])

这能跑,但类型信息很少。能建模型时,优先建模型:

class UserInfo(TypedDict):
    id: int
    name: str

def handle_user(user: UserInfo) -> None:
    print(user["name"])

坑五:Python 字段命名通常用 snake_case

前端接口常见:

interface User {
  userId: number;
  userName: string;
}

Python 内部更常见:

class User(TypedDict):
    user_id: int
    user_name: str

如果接口字段是 camelCase,后端内部要不要转成 snake_case,看项目约定。FastAPI + Pydantic 里通常可以用 alias 处理。

十六、工程里怎么写更实用

普通业务函数

参数和返回值尽量写清楚。

def calculate_total(items: list[CartItem]) -> float:
    total = 0.0

    for item in items:
        total += item["price"] * item["count"]

    return total

接口入参

FastAPI 项目里更常用 Pydantic。

from pydantic import BaseModel

class CreateOrderItem(BaseModel):
    product_id: str
    count: int

class CreateOrderRequest(BaseModel):
    user_id: int
    items: list[CreateOrderItem]
    coupon_code: str | None = None

内部数据结构

如果只是函数之间传递数据,可以用 TypedDictdataclass

from dataclasses import dataclass

@dataclass
class PriceResult:
    total: float
    discount: float
    payable: float

选择建议:

场景推荐
JSON / dict 结构TypedDict
内部纯数据对象dataclass
接口入参和运行时校验Pydantic BaseModel
固定状态字符串LiteralEnum
回调函数Callable
输入输出类型有关联泛型 TypeVar

十七、总结

Python 类型注解不要按“它会不会像 TS 一样严格”来理解,而要按工程用途理解:

  • 它让函数签名更清楚
  • 它让复杂 dict / list 结构更可读
  • 它让编辑器能补全和提示
  • 它让 pyright / mypy 能提前发现问题
  • 它和 Pydantic 搭配后,才更接近后端接口的运行时校验

前端迁移到 Python 时,可以先记这个顺序:

基础变量类型
  -> 函数参数和返回值
  -> list / dict / tuple / set
  -> union 和 None
  -> Literal 限制固定值
  -> TypedDict 描述 JSON/dict
  -> dataclass 描述内部数据对象
  -> Pydantic 处理接口入参校验
  -> Callable 和泛型处理更复杂的函数抽象