泛型

让类型也能"传参数",一份代码适配多种类型

🎯 学习目标

学完本章,你将能够:

  • 能够定义泛型函数、泛型接口和泛型类实现类型安全复用
  • 能够使用 extends 约束泛型参数的范围与形状
  • 能够理解 infer 关键字在条件类型中的推断机制
  • 能够运用泛型设计可复用的工具类型与工厂函数
  • 能够遵循泛型设计原则避免过度抽象与约束过紧

先看一个问题

如何写一个函数,既能处理 string,又能处理 number,还能保留类型信息?

// 方案1:any —— 能用,但丢失了类型信息
function identity(val: any): any {
    return val;
}
const result = identity("hello"); // any —— 不知道返回的是 string

// 方案2:写多个函数 —— 太重复
function identityString(val: string): string { return val; }
function identityNumber(val: number): number { return val; }

// 方案3:泛型 —— 完美解决
function identity<T>(val: T): T {
    return val;
}
const r1 = identity("hello");   // 自动推断 T = string
const r2 = identity(42);        // 自动推断 T = number

泛型让函数、接口、类可以参数化类型,实现类型级别的复用。

泛型是什么

类型的"占位符",使用时再填入具体类型

泛型(Generics)是一种在定义时不指定具体类型,而在使用时才确定类型的机制。就像函数的参数是"值的占位符",泛型的 <T> 就是"类型的占位符"。

打个比方:泛型就像一个模板——定义好格式,用的时候再填入具体内容。

// T 是类型参数,调用时自动推断或手动指定
function identity<T>(val: T): T {
    return val;
}

identity("hello");           // T = string,返回 string
identity(42);                // T = number,返回 number
identity<string>("hi");      // 显式指定 T = string

Part 1: 泛型函数

最常见的泛型使用场景,掌握它就掌握了泛型的核心思想

写一个函数,既能处理字符串数组,又能处理数字数组,还要保留类型信息——怎么做?泛型函数就是答案。接下来我们从最简单的 identity 函数开始,逐步掌握泛型函数的定义、推断和约束。学完这一节,你就能用泛型改写日常开发中几乎所有的工具函数。

常用泛型函数

从简单到实用的递进示例

// 获取数组第一个元素
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}
first([1, 2, 3]);       // number | undefined
first(["a", "b"]);      // string | undefined
// 安全地读取对象属性
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
const user = { name: "张三", age: 25 };
pluck(user, "name");  // string
pluck(user, "age");   // number
// pluck(user, "email"); // 报错!"email" 不是 user 的属性
// 合并两个对象
function merge<A, B>(a: A, b: B): A & B {
    return { ...a, ...b };
}
const merged = merge({ name: "张三" }, { age: 25 });
// { name: string; age: number }

Part 2: 泛型接口

API 响应、数据仓库等场景的标准模式

前后端对接时,API 响应的结构是固定的——有 code、message、data。但 data 的类型每个接口都不一样。用泛型接口 ApiResponse<T> 就能一套结构适配所有接口。数据仓库的 CRUD 模板也是同样的思路。来看具体写法。

泛型接口

让接口适配不同的数据类型

// API 响应——最经典的泛型接口
interface ApiResponse<T> {
    code: number;
    message: string;
    data: T;
}

interface User { name: string; age: number; }

// 使用时指定 T 的具体类型
const userRes: ApiResponse<User> = {
    code: 200,
    message: "ok",
    data: { name: "张三", age: 25 },
};

const listRes: ApiResponse<User[]> = {
    code: 200,
    message: "ok",
    data: [{ name: "张三", age: 25 }],
};
// 分页响应
interface PaginatedResponse<T> {
    data: T[];
    total: number;
    page: number;
    pageSize: number;
}

// 泛型仓库接口——CRUD 的类型安全模板
interface Repository<T> {
    findById(id: string): Promise<T | null>;
    findAll(): Promise<T[]>;
    create(data: Omit<T, "id">): Promise<T>;
    update(id: string, data: Partial<T>): Promise<T>;
    delete(id: string): Promise<void>;
}

Part 3: 泛型类

数据结构、事件系统等核心模式的基础

栈、队列、事件发射器——这些数据结构和工具类有个共同特点:它们不关心存储的是什么类型的数据,只关心操作逻辑。泛型类让一个 Stack 既能装数字又能装字符串,而且类型信息全程保留。写一次,到处复用。

泛型类

让类也能参数化类型

// 泛型栈
class Stack<T> {
    private items: T[] = [];

    push(item: T): void { this.items.push(item); }
    pop(): T | undefined { return this.items.pop(); }
    peek(): T | undefined { return this.items[this.items.length - 1]; }
    get size(): number { return this.items.length; }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.pop(); // 2

const stringStack = new Stack<string>();
stringStack.push("hello");
// stringStack.push(42); // 报错!
// 泛型事件发射器
class EventEmitter<T extends string> {
    private listeners = new Map<T, Set<Function>>();
    on(event: T, fn: Function): () => void {
        if (!this.listeners.has(event))
            this.listeners.set(event, new Set());
        this.listeners.get(event)!.add(fn);
        return () => this.listeners.get(event)?.delete(fn);
    }
    emit(event: T, ...args: unknown[]): void {
        this.listeners.get(event)?.forEach(fn => fn(...args));
    }
}

type AppEvents = "login" | "logout" | "error";
const emitter = new EventEmitter<AppEvents>();
emitter.on("login", (user: string) => console.log(user));
// emitter.on("unknown", () => {}); // 报错!不是 AppEvents

Part 4: 泛型约束

T 太宽泛?用 extends 给它加限制

泛型的 T 可以是任何类型——但有时候你并不想"来者不拒"。比如写 logLength 函数,T 得有 .length 属性才行。用 extends 给 T 加约束,既保留泛型的灵活性,又确保类型安全。keyof 约束还能让属性名访问绝对安全。

extends 约束

让泛型参数满足特定条件

// 问题:T 太宽泛,无法使用特定属性
function logLength<T>(val: T): void {
    // console.log(val.length); // 报错!T 不一定有 length
}

// 解决:用 extends 约束 T 必须有 length 属性
interface HasLength {
    length: number;
}

function logLength<T extends HasLength>(val: T): void {
    console.log(val.length); // OK!T 保证有 length
}

logLength("hello");        // OK,string 有 length
logLength([1, 2, 3]);      // OK,数组有 length
logLength({ length: 10 }); // OK,对象有 length 属性
// logLength(42);           // 报错!number 没有 length

keyof 约束

限制参数必须是对象的某个键

// 约束 K 必须是 T 的属性名
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "张三", age: 25 };
getProperty(user, "name");  // string
// getProperty(user, "email"); // 报错!"email" 不是 user 的属性

// 约束为对象类型
function merge<T extends object, U extends object>(a: T, b: U): T & U {
    return { ...a, ...b };
}

// new 类型约束——T 必须可以 new
function createInstance<T>(Ctor: new () => T): T {
    return new Ctor();
}
class Person { name = "张三"; }
const p = createInstance(Person); // Person

默认泛型参数

泛型参数可以有默认值,像函数参数一样

// 泛型参数可以有默认值
interface ApiResponse<T = unknown, E = string> {
    data: T;
    error?: E;
}

const res1: ApiResponse = { data: "hello" };
// T = unknown, E = string(都使用默认值)

const res2: ApiResponse<User> = { data: { name: "张三", age: 25 } };
// T = User, E = string

const res3: ApiResponse<User, number> = { data: { name: "张三", age: 25 } };
// T = User, E = number

Part 5: 多泛型参数

一个泛型不够用?那就用两个、三个

map 函数的输入和输出类型不同,需要两个泛型;zip 函数合并两个数组,需要三个。多泛型参数并不复杂——每个泛型各管各的类型流动,TypeScript 会自动推断。这一节看完,你就能理解那些看似复杂的工具函数签名了。

多泛型参数

处理不同类型之间的转换关系

// 两个泛型参数:map 转换
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
    return arr.map(fn);
}
map([1, 2, 3], n => n.toString()); // string[]
map(["a", "bb"], s => s.length);    // number[]

// 三个泛型参数:zip 合并
function zip<A, B, C>(a: A[], b: B[], fn: (x: A, y: B) => C): C[] {
    return a.map((item, i) => fn(item, b[i]));
}
zip([1, 2, 3], ["a", "b", "c"], (n, s) => ({ n, s }));
// [{ n: 1, s: "a" }, { n: 2, s: "b" }, { n: 3, s: "c" }]

综合实战

类型安全的事件系统

// 泛型 + 条件类型:类型安全的 EventEmitter
type EventMap = {
    login: { userId: string };
    logout: undefined;
    error: { message: string; code: number };
};

class TypedEmitter<T extends Record<string, any>> {
    on<K extends keyof T>(event: K, fn: (data: T[K]) => void): void {}
    emit<K extends keyof T>(event: K, data: T[K]): void {}
}

const typed = new TypedEmitter<EventMap>();
typed.on("login", (data) => {
    console.log(data.userId); // 自动推断为 string
});
typed.emit("error", { message: "crash", code: 500 });
// typed.emit("error", { message: "crash" }); // 报错!缺少 code

泛型命名惯例:T(Type)、K(Key)、V(Value)、E(Element)、R(Return)。多个类型参数用有意义的名称:TInput、TOutput。

三种方式对比

any、unknown 和泛型各有所长

方式类型安全类型保留适用场景
any无丢失快速原型、老代码迁移
unknown需收窄保留为 unknown不确定类型时
泛型 <T>完整保留具体类型函数/接口/类的类型复用
// 对比:同样的函数,三种写法
function idAny(val: any): any { return val; }
function idUnknown(val: unknown): unknown { return val; }
function idGeneric<T>(val: T): T { return val; }

const a = idAny("hi");       // any —— 什么都能做,但不安全
const b = idUnknown("hi");   // unknown —— 必须先收窄才能用
const c = idGeneric("hi");   // string —— 完整保留类型信息

Part 6: 进阶实战

这部分是分水岭。接下来你将看到 infer、条件类型、模板字面量类型、依赖注入容器——它们在生产级 TS 库(Prisma、tRPC、NestJS)里随处可见,但理解成本明显高于前五章。

第一次学泛型,可以跳过本节,直接去看"泛型设计原则"和后面的练习。等你写过几个真实项目、回头再读,会顺畅得多。这不是偷懒,是把难点放到合适的时机。

如果你选择继续——下面这些例子按难度递增。卡住很正常,infer 和递归类型本身就需要反复看几遍。一次读不完没关系。

infer 揭秘

在条件类型中"捕获"子类型

infer 就像类型世界里的解构赋值。你在模式匹配的位置放一个 infer R,TypeScript 会帮你把匹配到的类型提取到 R 里。

但和解构赋值不同的是:infer 只能在 extends ? : 条件类型的右侧出现,并且它不"执行"任何东西——它只是在编译器做模式匹配时,给匹配到的类型起个名字。运行时这段类型信息已经被擦除了,不存在。
// 提取函数返回类型——ReturnType 的实现原理
type MyReturn<T> = T extends (...args: any[]) => infer R ? R : never;

type A = MyReturn<() => string>;              // string
type B = MyReturn<(x: number) => boolean>;    // boolean
type C = MyReturn<string>;                    // never(不是函数)
// 提取 Promise 内部类型
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;

type P1 = Unwrap<Promise<string>>;                    // string
type P2 = Unwrap<Promise<Promise<number>>>;           // number
type P3 = Unwrap<Promise<Promise<Promise<boolean>>>>; // boolean

// 提取数组第一个元素类型
type Head<T> = T extends [infer F, ...any[]] ? F : never;
type H = Head<[string, number, boolean]>; // string

关于递归:Unwrap<T> 在自己内部又调用了 Unwrap<U>,这就是递归条件类型。TypeScript 会一层层剥开 Promise,直到不再是 Promise 为止。和写递归函数是同一种思路。

类型如何传播

从调用处推断类型参数到函数内部的传播过程

理解泛型的关键是搞清楚"类型是怎么流动的"。来看一个完整的过程:

function first<T>(arr: T[]): T | undefined {
    return arr[0];
}

// 调用处:传入 string[]
const result = first(["hello", "world"]);
// 1. TS 看到 ["hello", "world"],推断为 string[]
// 2. 参数 arr: T[] 匹配 string[],所以 T = string
// 3. 返回值 T | undefined 变成 string | undefined
// 4. result 的类型:string | undefined
图:泛型类型推断的传播流程

核心原则:类型从调用处流入,经过函数体,再从返回值流出。整个过程由 TypeScript 编译器自动完成。

泛型工厂

用泛型创建类型安全的工厂函数

工厂模式 + 泛型 = 类型安全的对象创建。在写库和框架的时候特别有用。

// 类型安全的工厂函数
function createFactory<T>(defaults: T) {
    return (overrides: Partial<T> = {}): T => {
        return { ...defaults, ...overrides };
    };
}

interface User {
    name: string;
    age: number;
    role: "admin" | "user";
}

const createUser = createFactory<User>({
    name: "匿名",
    age: 0,
    role: "user",
});

const u1 = createUser({ name: "张三" });
// { name: "张三", age: 0, role: "user" }

const u2 = createUser({ name: "李四", age: 25, role: "admin" });
// createUser({ role: "super" }); // 报错!"super" 不是合法值
// 泛型 + 约束:带验证的工厂
function createValidated<T extends object>(
    schema: { [K in keyof T]: (val: unknown) => T[K] },
) {
    return (input: Record<string, unknown>): T => {
        const result = {} as T;
        for (const key in schema) {
            result[key] = schema[key](input[key]);
        }
        return result;
    };
}

泛型函数重载

泛型与函数重载结合,实现类型级别的条件分支

有时候我们希望函数的行为根据输入类型动态变化——传入 string 返回 number,传入 number 返回 string。泛型 + 条件类型 + 函数重载可以优雅地实现这一点。

// 泛型 + 条件类型 = 返回值类型随输入变化
function convert<T extends string | number>(value: T): T extends string ? number : string;
function convert(value: string | number): string | number {
    if (typeof value === "string") return Number(value);
    return String(value);
}

const a = convert("42");    // number —— TS 知道返回的是数字
const b = convert(42);      // string —— TS 知道返回的是字符串
// const c: number = convert(42); // 报错!convert(42) 返回 string
// 实战:类型安全的 JSON 解析器
function parseJSON<T>(input: string, validator: (data: unknown) => data is T): T;
function parseJSON<T>(input: string, validator?: Function): unknown;
function parseJSON<T>(input: string, validator?: Function): T | unknown {
    const data = JSON.parse(input);
    if (validator && validator(data)) return data as T;
    return data;
}

// 有 validator 时返回具体类型,没有时返回 unknown
const safe = parseJSON('{"name":"张三"}', (d: any): d is User => typeof d.name === "string");
// safe: User

注意:重载签名必须和实现签名兼容。实现签名是 string | number,重载签名返回的条件类型必须是这个联合的子集,否则会报错。

深层属性安全

用 keyof + 泛型构建类型安全的多层属性访问

前面我们学过 getProperty,但实际开发中经常需要访问嵌套属性。让 keyof 和泛型结合起来,实现深层类型安全。

// 基础版:单层属性访问
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "张三", age: 25, address: { city: "北京" } };
getProperty(user, "name");    // string
getProperty(user, "age");     // number
// getProperty(user, "phone"); // 报错!"phone" 不是 user 的属性
// 进阶版:类型安全的链式 get
type PathImpl<T, K extends keyof T> =
    K extends string
    ? T[K] extends object
        ? `${K}.${keyof T[K] & string}` | K
        : K
    : never;

type Path<T> = PathImpl<T, keyof T>;

// 使用示例
type UserPath = Path<{ name: string; address: { city: string; zip: number } }>;
// "name" | "address" | "address.city" | "address.zip"

设计思路:keyof T 告诉你"对象有哪些属性",T[K] 告诉你"这个属性的类型是什么"。两者组合就能在编译期保证属性访问的绝对安全——拼错属性名直接报错,而不是运行时 undefined。

构造函数签名

用泛型抽象"可以 new 的东西"

在写依赖注入、工厂模式、ORM 这类框架级代码时,你经常需要把类本身当作参数传递。这时就需要构造函数签名。

// 核心类型:描述"可以用 new 调用、返回 T 的构造函数"
type Constructor<T> = new (...args: any[]) => T;

// 用泛型工厂创建实例
function createInstance<T>(Ctor: Constructor<T>, ...args: any[]): T {
    return new Ctor(...args);
}

class Person {
    constructor(public name: string, public age: number) {}
}
const p = createInstance(Person, "张三", 25);
// p: Person —— 类型完整保留
// 实战:依赖注入容器
class Container {
    private registry = new Map<string, Constructor<any>>();
    private instances = new Map<string, any>();

    register<T>(key: string, Ctor: Constructor<T>): void {
        this.registry.set(key, Ctor);
    }

    resolve<T>(key: string): T {
        if (this.instances.has(key)) return this.instances.get(key);
        const Ctor = this.registry.get(key);
        if (!Ctor) throw new Error(`未注册: ${key}`);
        const instance = new Ctor();
        this.instances.set(key, instance);
        return instance as T;
    }
}

const container = new Container();
container.register("logger", ConsoleLogger);
const logger = container.resolve<ConsoleLogger>("logger");

常见陷阱:new (...args: any[]) => T 和 () => T 是不同的。前者是构造函数签名(用 new 调用),后者是普通工厂函数(直接调用)。传 class 时必须用构造函数签名。

泛型设计原则

写出优雅、可维护的泛型代码的四条原则

泛型很强大,但滥用会让代码变成"类型天书"。这里总结四条经过实战验证的设计原则。

原则一:不要过度泛化

// 过度泛化:明明只有两种情况,非要搞泛型
function format<T extends string | number>(val: T): string {
    return String(val);
}

// 更好的做法:直接写联合类型,简单明了
function format(val: string | number): string {
    return String(val);
}

经验法则:如果泛型只用到一个地方,而且类型数量有限(≤3),联合类型往往更清晰。

原则二:优先让 TS 自动推断

// 不好的写法:到处手动指定
const r1 = identity<string>("hello");
const r2 = first<number>([1, 2, 3]);

// 好的写法:让 TS 自己推断
const r1 = identity("hello");   // 自动推断 T = string
const r2 = first([1, 2, 3]);    // 自动推断 T = number

什么时候手动指定:当推断不出来(如无参函数 create<User>())或推断结果不够精确时。

约束设计技巧

原则三和原则四:约束要恰到好处

原则三:约束粒度要合适

// 太松:T 可以是 anything,等于没约束
function getProperty<T>(obj: T, key: string): any {
    return (obj as any)[key]; // 完全丢失类型安全
}

// 太严:限制死了只能是特定接口
function getUserName<T extends { name: string; age: number }>(obj: T): string {
    return obj.name;
}

// 刚好:只约束需要的属性
function getName<T extends { name: string }>(obj: T): string {
    return obj.name;
}

原则四:泛型参数越少越好

// 过多泛型:阅读者需要理解 4 个类型参数
function transform<A, B, C, D>(input: A, ab: (a: A) => B,
    bc: (b: B) => C, cd: (c: C) => D): D {
    return cd(bc(ab(input)));
}

// 更好的做法:减少显式泛型,让推断工作
function pipe<T>(input: T, ...fns: Function[]): any {
    return fns.reduce((acc, fn) => fn(acc), input);
}

总结:能用 1 个泛型参数解决的,不要用 2 个。能推断的,不要手写。约束要恰好覆盖你的需求——不多不少。

泛型与异步

泛型在异步编程中的经典应用模式

前端开发中,异步操作无处不在。泛型让异步代码的类型安全达到和同步代码一样的水平。

// 最常见的模式:泛型 + async/await
async function fetchJson<T>(url: string): Promise<T> {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json() as Promise<T>;
}

// 使用时获得完整的类型推断
interface Todo { id: number; title: string; completed: boolean }
const todo = await fetchJson<Todo>("/api/todos/1");
console.log(todo.title); // 类型安全!
// 进阶:带缓存的异步函数
function cachedFetch<T>() {
    const cache = new Map<string, Promise<T>>();
    return async (url: string): Promise<T> => {
        if (cache.has(url)) return cache.get(url)!;
        const promise = fetchJson<T>(url);
        cache.set(url, promise);
        return promise;
    };
}

const fetchUser = cachedFetch<User>();
await fetchUser("/api/user/1"); // 网络请求
await fetchUser("/api/user/1"); // 命中缓存

注意:response.json() 返回的是 Promise<any>,用 as Promise<T> 断言不会做运行时校验。在生产代码中,建议搭配运行时验证库(如 zod)做双重保障。

事件系统实战

练习

任务:实现一个类型安全的 EventEmitter。用泛型 + 映射类型定义事件名和事件数据的对应关系,支持 on、emit、off 三个方法。

// 目标效果:
// 1. on("login", (data) => {}) 中 data 自动推断
// 2. emit("login", { userId: "1" }) 参数类型校验
// 3. off("login", handler) 移除监听
// 4. on("unknown", ...) 报错!不在事件映射中
// 你的代码:

参考答案

// 事件映射:事件名 → 数据类型
type EventMap = {
    login: { userId: string };
    logout: undefined;
    error: { message: string; code: number };
};

type Handler<T> = T extends undefined ? () => void : (data: T) => void;

class TypedEmitter<T extends Record<string, any>> {
    private listeners = new Map<keyof T, Set<Handler<any>>>();

    on<K extends keyof T>(event: K, fn: Handler<T[K]>): () => void {
        if (!this.listeners.has(event))
            this.listeners.set(event, new Set());
        this.listeners.get(event)!.add(fn);
        return () => this.off(event, fn);
    }

    off<K extends keyof T>(event: K, fn: Handler<T[K]>): void {
        this.listeners.get(event)?.delete(fn);
    }

    emit<K extends keyof T>(event: K, ...args: T[K] extends undefined ? [] : [T[K]]): void {
        this.listeners.get(event)?.forEach(fn => fn(...args as any));
    }
}

const emitter = new TypedEmitter<EventMap>();
emitter.on("login", (data) => console.log(data.userId));
emitter.emit("login", { userId: "123" });
// emitter.emit("login", {}); // 报错!缺少 userId

常见问题

问题一:泛型推断不出来

// 泛型推断依赖参数,没有参数就推断不出
function create<T>(): T { return {} as T; }
const obj = create(); // T = unknown,丢失了

解决:显式指定泛型参数 create<User>()。

问题二:约束太严或太松

// 太松:T 可以是任何东西
function print<T>(val: T) { console.log(val); } // OK 但不够精确

// 太严:只接受 string
function print<T extends string>(val: T) { } // number 就进不来

解决:根据实际需要选择约束程度,优先让 TS 自动推断。

试一试

练习一

任务:写一个泛型函数 wrapper,接受任意值并返回包含该值的数组。

// wrapper("hello") 应返回 ["hello"]
// wrapper(42) 应返回 [42]
// 你的代码:

参考答案

function wrapper<T>(val: T): T[] {
    return [val];
}
wrapper("hello"); // string[]
wrapper(42);      // number[]

练习二

任务:写一个泛型函数 pluck,从对象数组中提取某个属性的所有值。

// pluck([{name:"A",age:1},{name:"B",age:2}], "name")
// 应返回 ["A", "B"]
// 你的代码:

参考答案

function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
    return arr.map(item => item[key]);
}
pluck([{name:"A",age:1},{name:"B",age:2}], "name");
// string[]

📝 要点总结

本章核心知识点

泛型基础

类型参数 T、多参数、默认类型参数

约束与推断

extends 约束形状、keyof 约束键、infer 提取类型

泛型函数/接口/类

三种场景的泛型声明方式与最佳实践

设计原则

延迟指定类型、约束最小化、避免嵌套过深

实战模式

泛型工厂、泛型事件系统、泛型异步处理

下一章

泛型让类型也能传参数。下一章我们学习高级类型操作——用类型编程,让类型系统为你工作。

1 / X