第8章 8.2 泛型与高级类型

🎯 为什么要学泛型?

上一章我们学会了 TypeScript 入门,能给变量标类型了。但你有没有遇到过这种情况——

写了一个函数,输入是数字返回数字,输入是字符串返回字符串,输入是数组返回数组。类型该怎么标?总不能写 number | string | string[] | number[] 吧?

这就是泛型要解决的问题。

学完这章你能:
- 写出「通用」函数,一种写法适配所有类型
- 理解 TypeScript 内置的高级类型(Partial、Pick、keyof)
- 搞清楚联合类型和交叉类型的区别


🧱 基础:泛型是个啥?

泛型函数:会变身的参数

生活类比:想象一下有个「万能塑料袋」,装苹果就是苹果袋,装水就是水袋。泛型就是 TypeScript 里的「万能塑料袋」——函数参数的类型,等你调用时才知道

为什么要用:避免写重复的函数,比如 filterNumbers()filterStrings() 可以合并成一个。
\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n

# Python 3.11+ 泛型语法
from typing import TypeVar, Generic

# 定义一个「万能」的泛型变量 T(就像给塑料袋贴个标签)
T = TypeVar('T')

# 这是一个泛型函数,输入啥类型就返回啥类型
def first_element(items: list[T]) -> T | None:
"""返回列表的第一个元素,没有就返回 None"""
return items[0] if items else None

# 试试看
numbers = [1, 2, 3, 4, 5]
words = ["苹果", "香蕉", "橙子"]

print(first_element(numbers))  # 输出: 3
print(first_element(words))    # 输出: 橙子

这行 T = TypeVar('T') 就是在声明:「我要用 T 这个字母代表一种类型,具体啥类型等调用的时候再说」。

泛型约束:给塑料袋加个功能限制

生活类比:万能塑料袋虽然啥都能装,但如果你要装「能吃东西的」,那就得限制——只能是食物。泛型约束就是加条件。

为什么要用:有时候需要对泛型类型做操作(比如访问 .length),但 TypeScript 不知道这个类型有没有这个属性,就需要约束。

from typing import TypeVar

T = TypeVar('T')

# 约束 T 必须有 length 属性(必须是「有长度的」类型)
def get_length(item: T) -> int:
"""这行会报错!因为 T 不一定有 length"""
return len(item)  # 报错:TypeError: object of type 'T' has no len()

上面这个例子会报错,因为 TypeScript 在编译时不知道 T 是什么类型,len() 能不能用。

from typing import TypeVar, Generic

T = TypeVar('T')

class HasLength:
"""有个长度的东西"""
def __init__(self, value: str | list):
    self.value = value

def length(self) -> int:
    return len(self.value)

# 用泛型约束限制 T 必须是 HasLength 的子类
def print_length(item: T) -> None:
print(f"长度是: {item.length()}")

# 测试一下
has_length_str = HasLength("hello")
has_length_list = HasLength([1, 2, 3])

print_length(has_length_str)   # 输出: 长度是: 5
print_length(has_length_list)  # 输出: 长度是: 3

多个泛型参数:不止一个变身

from typing import TypeVar

K = TypeVar('K')
V = TypeVar('V')

def make_pair(key: K, value: V) -> tuple[K, V]:
"""打包成元组返回"""
return (key, value)

result1 = make_pair("name", "小明")
result2 = make_pair(1, True)

print(result1)  # 输出: ('name', '小明')
print(result2)  # 输出: (1, True)

🧱 基础:高级类型(工具箱里的神器)

keyof:把所有键抽出来

生活类比:keyof 就像从一栋楼里抽出门牌号。「keyof Person」就是从 Person 类型里抽出所有属性名。

from typing import TypeVar, Literal

T = TypeVar('T')
K = Literal["name", "age", "city"]

# 模拟 keyof 的效果
def get_property(obj: dict, key: K) -> any:
"""根据 key 获取属性值"""
return obj.get(key)

person = {"name": "小明", "age": 25, "city": "北京"}

print(get_property(person, "name"))  # 输出: 小明
print(get_property(person, "age"))   # 输出: 25

Partial:所有属性变可选

生活类比:填表时有些字段可以不填。Partial 就是把一个类型的所有属性都变成可选的。

from typing import Optional

# 模拟 Partial 的效果
class User:
def __init__(self, name: str, age: int, email: str):
    self.name = name
    self.age = age
    self.email = email

@classmethod
def partial(cls, data: dict) -> "User":
    """用字典部分字段创建 User(可选字段默认为 None)"""
    return cls(
        name=data.get("name", ""),
        age=data.get("age", 0),
        email=data.get("email", "")
    )

# 全字段创建
full_user = User("小明", 25, "xiaoming@example.com")
print(f"完整用户: {full_user.name}, {full_user.age}")

# 部分字段创建
partial_user = User.partial({"name": "小红"})
print(f"部分用户: {partial_user.name}, {partial_user.age}")  # age 默认为 0

Required:所有属性变必填

跟 Partial 相反,Required 把所有可选属性变成必填。

Pick:挑几个属性出来

from typing import TypeVar, Literal

# 模拟 Pick 的效果:只保留某些字段
def pick_fields(obj: dict, fields: list[str]) -> dict:
"""从对象里挑出指定字段"""
return {k: v for k, v in obj.items() if k in fields}

person = {"name": "小明", "age": 25, "email": "xiaoming@example.com", "city": "北京"}

# 只挑 name 和 email
short = pick_fields(person, ["name", "email"])
print(short)  # 输出: {'name': '小明', 'email': 'xiaoming@example.com'}

联合类型:或的关系

生活类比:联合类型就像「或门」——可以是苹果或者香蕉,但不能是苹果和香蕉的混合物。

from typing import Union

# 联合类型:可能是字符串也可能是数字
def process(value: Union[str, int]) -> str:
if isinstance(value, str):
    return f"字符串长度: {len(value)}"
else:
    return f"数字翻倍: {value * 2}"

print(process("hello"))  # 输出: 字符串长度: 5
print(process(42))       # 输出: 数字翻倍: 84

交叉类型:且的关系

生活类比:交叉类型就像「与门」——必须同时满足两个类型,就像你既是学生又是未成年。

from typing import Protocol

class HasName:
def __init__(self, name: str):
    self.name = name

class HasAge:
def __init__(self, age: int):
    self.age = age

# 交叉类型:同时有 name 和 age
class Student(HasName, HasAge):
def __init__(self, name: str, age: int, grade: str):
    super().__init__(name)
    HasAge.__init__(self, age)
    self.grade = grade

student = Student("小明", 15, "初三")
print(f"{student.name} 今年 {student.age} 岁,在读 {student.grade}")

🔥 实战:3 个递进小项目

项目 1(5 分钟):泛型版数组工具箱

跟着抄就能跑,理解核心 API。

from typing import TypeVar, Generic

T = TypeVar('T')

class ArrayTool(Generic[T]):
"""泛型数组工具箱"""

def __init__(self, items: list[T]):
    self.items = items

def first(self) -> T | None:
    """获取第一个元素"""
    return self.items[0] if self.items else None

def last(self) -> T | None:
    """获取最后一个元素"""
    return self.items[-1] if self.items else None

def filter(self, condition: callable) -> "ArrayTool[T]":
    """按条件过滤"""
    return ArrayTool([item for item in self.items if condition(item)])

# 测试
numbers = ArrayTool([1, 2, 3, 4, 5])
words = ArrayTool(["苹果", "香蕉", "橙子"])

print(numbers.first())              # 输出: 1
print(words.last())                 # 输出: 橙子
print(numbers.filter(lambda x: x > 2).items)  # 输出: [3, 4, 5]

预期输出

1
橙子
[3, 4, 5]

一句话解释:创建了一个「泛型工具箱」,处理数字列表和字符串列表用的是同一套代码。


项目 2(15 分钟):从 JSON 数据中提取信息

加入真实场景需求,读数据、处理数据。

from typing import TypeVar, Generic, Optional
import json

T = TypeVar('T')

class DataExtractor(Generic[T]):
"""从 JSON 数据中提取信息"""

def __init__(self, data: dict):
    self.data = data

def get(self, key: str, default: Optional[T] = None) -> Optional[T]:
    """安全获取嵌套属性(支持点号语法)"""
    keys = key.split('.')
    current = self.data

    for k in keys:
        if isinstance(current, dict) and k in current:
            current = current[k]
        else:
            return default
    return current

def filter_by(self, key: str, value: any) -> list[dict]:
    """筛选出指定属性等于某值的记录"""
    return [
        item for item in self.data 
        if isinstance(item, dict) and item.get(key) == value
    ]

# 模拟从 API 拿到的数据
api_response = json.loads('''
[
{"id": 1, "name": "iPhone 15", "category": "手机", "price": 6999},
{"id": 2, "name": "MacBook Pro", "category": "电脑", "price": 12999},
{"id": 3, "name": "AirPods Pro", "category": "耳机", "price": 1899},
{"id": 4, "name": "小米 14", "category": "手机", "price": 3999}
]
''')

extractor = DataExtractor(api_response)

# 获取第一个商品的名称
first_name = extractor.get("0.name")
print(f"第一个商品: {first_name}")

# 筛选所有手机
phones = extractor.filter_by("category", "手机")
print(f"手机分类: {[p['name'] for p in phones]}")

# 获取不存在的属性,给默认值
nonexistent = extractor.get("0.non_existent", "默认值")
print(f"不存在的属性: {nonexistent}")

预期输出

第一个商品: iPhone 15
手机分类: ['iPhone 15', '小米 14']
不存在的属性: 默认值

一句话解释:get() 方法用点号语法可以读取嵌套属性,再也不怕 KeyError 了。


项目 3(15 分钟):做一个配置合并工具

组合前两个项目的能力,做个有点真实用的小工具。

from typing import TypeVar, Generic, Optional, Union
import json

T = TypeVar('T')

class ConfigMerger:
"""配置合并工具 - 演示交叉类型"""

@staticmethod
def merge(base: dict, override: dict) -> dict:
    """合并两个配置,override 的值会覆盖 base 的值"""
    result = base.copy()

    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            # 如果两者都是字典,递归合并
            result[key] = ConfigMerger.merge(result[key], value)
        else:
            result[key] = value

    return result

class TypedConfig(Generic[T]):
"""带类型检查的配置读取器"""

def __init__(self, config: dict, schema: dict):
    self.config = config
    self.schema = schema

def get(self, key: str, expected_type: type) -> Optional[T]:
    """按类型安全获取配置"""
    value = self.config.get(key)

    if value is None:
        return None

    if not isinstance(value, expected_type):
        print(f"⚠️ 类型警告: {key} 期望 {expected_type.__name__},实际 {type(value).__name__}")
        return None

    return value

def validate(self) -> tuple[bool, list[str]]:
    """验证配置是否符合 schema 要求"""
    errors = []

    for key, expected_type in self.schema.items():
        if key not in self.config:
            errors.append(f"缺少必填配置: {key}")
        elif not isinstance(self.config[key], expected_type):
            errors.append(f"{key} 类型错误,期望 {expected_type.__name__}")

    return len(errors) == 0, errors

# 模拟使用场景:游戏配置文件
default_config = {
"audio": {"music": 80, "sfx": 70},
"video": {"resolution": "1920x1080", "fps": 60},
"difficulty": "normal"
}

user_config = {
"audio": {"music": 50},  # 只覆盖 music,sfx 保留默认值
"difficulty": "hard"
}

# 合并配置
merged = ConfigMerger.merge(default_config, user_config)
print("合并后的配置:")
print(json.dumps(merged, indent=2))

# 类型安全读取
schema = {"audio": dict, "video": dict, "difficulty": str}
typed = TypedConfig(merged, schema)

music_volume = typed.get("audio.music", int)  # 注意:这里简化了,实际需要更复杂的路径解析
print(f"\n音乐音量: {merged['audio']['music']}")

# 验证配置
is_valid, errors = typed.validate()
print(f"配置验证: {'通过' if is_valid else '失败'}")
if errors:
for error in errors:
    print(f"  - {error}")

预期输出

合并后的配置:
{
"audio": {
"music": 50,
"sfx": 70
},
"video": {
"resolution": "1920x1080",
"fps": 60
},
"difficulty": "hard"
}

音乐音量: 50

配置验证: 通过

一句话解释:配置合并用递归处理嵌套字典,TypedConfig 帮你检查配置类型对不对。


💪 进阶:常见坑 + 小贴士

坑 1:泛型类型推断失败

# ❌ 错误示例:TypeScript 里这样写会报错
# def identity(x: T) { return x }  // T 未定义

# ✅ 正确示例:必须先声明 TypeVar
from typing import TypeVar

T = TypeVar('T')

def identity(x: T) -> T:
return x

坑 2:Union 和 str | int 混用

from typing import Union

# ❌ 错误:在不支持 | 语法的旧版 typing
# def foo(x: str | int): pass

# ✅ 正确:用 Union
def foo(x: Union[str, int]) -> str:
return str(x)

坑 3:泛型约束忘记加边界检查

# ❌ 错误示例
T = TypeVar('T')

def first_or_default(items: list[T]) -> T:
return items[0]  # 空列表会报错!

# ✅ 正确示例
def first_or_default(items: list[T], default: T) -> T:
return items[0] if items else default

坑 4:Partial 和必填属性混用

# ❌ 错误示例:有些字段必填,有些可选,容易混淆
class User:
def __init__(self, name: str, email: Optional[str] = None):
    self.name = name
    self.email = email

# ✅ 正确示例:用 @overload 定义多种调用方式
from typing import overload

class User2:
@overload
def __init__(self, name: str, email: str):
    ...

@overload
def __init__(self, name: str, email: None):
    ...

def __init__(self, name: str, email: str | None = None):
    self.name = name
    self.email = email

坑 5:交叉类型和接口继承分不清

# ❌ 错误示例:滥用交叉类型
# class A(B, C): pass  # 继承两个类

# ✅ 正确示例:用接口继承
class Base:
pass

class Derived(Base):
pass

调试技巧:print 大法

# 泛型不工作?先 print 看看类型
T = TypeVar('T')

def debug_type(x: T) -> T:
print(f"传入类型: {type(x).__name__}")  # 运行时打印类型
return x

debug_type("hello")  # 输出: 传入类型: str
debug_type(123)      # 输出: 传入类型: int

✏️ 练习题

练习 1(1 分钟):抄改泛型函数

  • 输入[10, 20, 30]["a", "b", "c"]
  • 预期输出30c
  • 提示:把项目 1 的 last() 方法拿来用,改个变量名就行

练习 2(2 分钟):加个条件判断

  • 输入[1, 2, 3, 4, 5],条件是 x > 3
  • 预期输出[4, 5]
  • 提示:在项目 1 的 filter 后面再加一行判断

练习 3(2 分钟):换个数据源

  • 输入[{"name": "A", "score": 90}, {"name": "B", "score": 80}]
  • 预期输出:筛选出 score > 85 的记录
  • 提示:用项目 2 的 filter_by 方法,改一下字段名

练习 4(3 分钟):串联两个项目

  • 输入:产品列表,筛选出价格 > 5000 的,然后取最贵的那个
  • 预期输出:价格最高的产品对象
  • 提示:先 filter_by,再 first()

练习 5(2 分钟):分析报错

  • 输入:下面这段代码为什么报错?
T = TypeVar('T')

def broken(items: list[T]) -> T:
return items[0]
  • 预期输出:指出问题所在
  • 提示:空列表的情况没考虑

📚 作业:做一个「第8章 8.2 泛型与高级类型实战工具」

需求描述:做一个「数据转换器」,能读取 JSON 配置文件,合并默认配置,按 schema 验证类型,并支持查询。

功能点
1. 读取 JSON 配置文件
2. 合并默认配置(默认配置在代码里定义)
3. 按 schema 验证类型
4. 安全查询嵌套属性

加分项
1. 支持从文件读取配置
2. 支持导出合并后的配置到文件

验收标准
- 能跑起来
- 输出符合预期
- 代码有注释

提交方式:评论区贴代码或 GitHub 链接


📚 总结

这一章我们学了 3 个核心点:
1. 泛型函数:一种写法适配所有类型
2. 高级类型:Partial、Pick、keyof 让类型操作更灵活
3. 联合 vs 交叉:「或」和「且」的区别

推荐资源
- TypeScript 官方文档 - 泛型
- 《TypeScript 入门与实战》- 相关章节
- B站视频:TypeScript 泛型详解

互动钩子:你在实际项目中用过泛型吗?有没有遇到过泛型类型推断翻车的情况?评论区聊聊,老粉优先回复!


下一章我们要学习 Node.js 基础,对比它和浏览器里 JavaScript 的区别。学完你就知道为什么 console 在浏览器和 Node 里都能用,但 document 只有浏览器认识。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。