08 泛型入门:表达输入与输出的关系
对应项目:PageResult<T>、ApiResponse<T>。暂时不需要复杂条件类型,先掌握最常用的关系。
1. 为什么不能把泛型理解成“随便什么类型”?
function wrap<T>(value: T): { data: T } {
return { data: value };
}
const number_result = wrap(123); // data 是 number
const text_result = wrap("ANN"); // data 是 string
T 是类型参数,由调用时的输入决定;它保留了输入与输出的对应关系。
如果输出写成 unknown,调用者还需再收窄;如果写成 any,则很多检查失效。泛型的价值是有根据地把关系表达出来,而不是放宽约束。
2. 类型参数不是运行时参数
function wrap<T>(value: T): { data: T } {
return { data: value };
}
- T:编译期类型参数,运行时消失。
- value:运行时函数参数,真正保存数据。
不能在函数里 console.log(T),也不能 new T() 自动创建任意类型。需要构造行为时传入真正的构造器/工厂,这属于更进一步的设计。
3. 当前 PageResult<T> 为什么实用?
import type { PageResult } from "../../types/api";
interface CustomerNameDto {
customer_id: number;
first_name: string;
}
const page: PageResult<CustomerNameDto> = {
page: 1,
page_size: 20,
total: 1,
total_pages: 1,
items: [{ customer_id: 1, first_name: "ANN" }],
};
分页字段固定,items 的元素类型按用途变化。Actor、Customer、Admin 列表不必各写一遍分页容器。
泛型不计算 total_pages,也不校验页码范围。数字值正确与结构类型正确仍是两件事。
4. extends:约束泛型能接受什么
function getCustomerId<T extends { customer_id: number }>(value: T): number {
return value.customer_id;
}
意味着传入类型至少具有 customer_id:number。这里 extends 是约束,不是运行时继承。
但是这个例子并没有在返回值中保留 T,也未建立其他关系;直接写普通参数往往更简单:
function getCustomerId(value: { customer_id: number }): number {
return value.customer_id;
}
不是出现可复用函数就必须加泛型。类型参数只有在表达有价值的关系时才值得保留。
5. K extends keyof T:字段名与返回值相互对应
function getValue<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
const customer = { customer_id: 1, first_name: "ANN" };
const id = getValue(customer, "customer_id"); // number
const name = getValue(customer, "first_name"); // string
getValue(customer, "password_hash"); // 错误,不存在该键
拆解:T 是对象类型;K 必须属于 T 的键;T[K] 是该键对应的值类型。这是第 02 章 操作符的组合。
若字段本身可选,返回类型仍包含 undefined。泛型不能把缺失数据变成存在。
普通属性访问本来清楚时直接 customer.first_name 即可,不需要为了使用泛型而包装每次读取。
6. 推断与显式指定
function makeItems<T>(items: T[]): T[] {
return items;
}
makeItems([1, 2]); // 通常自动推断 T 为 number
makeItems<number>([]); // 空输入时可以明确想要 number[]
内部有清楚的参数关系时,尽量让 TS 推断。第三方 API 的结果类型无法从参数自然得出时,可以显式指定,但需要确保声明有真实依据。
例如 db.query<Row>() 的 Row 来自你对 SQL 的了解,不是编译器解析 SQL 的结果。
7. parseJson<T> 的危险错觉
function parseJson<T>(text: string): T {
return JSON.parse(text);
}
const customer = parseJson<{ customer_id: number }>('{}');
// 类型说有 customer_id,运行时却没有。
它让调用者可以凭空指定任何返回类型,没有检查结构。这种泛型声明不应被当成安全解析。
更诚实的起点:
function parseJson(text: string): unknown {
return JSON.parse(text);
}
然后调用真实类型守卫/验证器。必须处理 JSON 语法错误与结构错误,两者不同。
如果设计 parseAndValidate<T>(text, validator),T 应由可信 validator 的契约关联出来,而不是仅由调用者自由指定。
8. 常见泛型类型的阅读方法
| 类型 | 读法 |
|---|---|
Array<T> |
T 的数组 |
Promise<T> |
成功完成后的值是 T |
Record<K, V> |
键范围 K,值类型 V |
Pick<T, K> |
从 T 选择 K 这些属性 |
PageResult<T> |
items 中每项是 T 的分页结果 |
ApiResponse<T> |
项目定义的响应结构,成功数据按 T 表达 |
不同库可能给字母起不同名字,名字本身没有魔法。阅读定义,看该参数最终用于哪些字段或方法。
9. 什么时候值得抽象?
两个以上场景确实共享稳定结构,并且泛型能保持各自具体类型时,抽象有价值。
如果要写很多 as unknown as T 才能实现一个“通用 Service”,可能是抽象边界不合适。当前练习保留具体 DTO 与参数,公共容器用简单泛型即可。
10. 练习
- 给 Actor 名字 DTO 创建 PageResult,不复制分页字段。
- 用 getValue 读取可选邮箱,解释为什么可能 undefined。
- 找一个 T 只出现一次的函数,判断普通类型能否更清楚。
- 给 parseJson 增加真实检查,证明泛型本身不校验 JSON。
下一章:Promise、await 与函数类型派生。参考:Generics。