TypeScript 泛型从入门到精通:让类型为你工作

很多人对 TypeScript 的认识停在"给变量加类型注解"。这其实只用到了 5% 的能力。TypeScript 真正强大的地方在泛型类型推导 —— 它们让类型系统能像写代码一样"编程"。这篇文章带你从最基础的 <T> 一路走到条件类型、映射类型、类型推导,所有例子都来自真实工程。

泛型解决的根本问题:复用 + 类型安全

看一个没有泛型的笨方法:

function firstString(arr: string[]): string { return arr[0]; }
function firstNumber(arr: number[]): number { return arr[0]; }
// 还要为每种类型写一遍...

any 复用?可以,但失去了类型安全:

function firstAny(arr: any[]): any { return arr[0]; }
const x = firstAny([1, 2, 3]);
x.toUpperCase();   // 编译通过,运行时崩溃

泛型同时解决"复用"和"安全":

function first<T>(arr: T[]): T { return arr[0]; }
const a = first([1, 2, 3]);        // a 是 number,自动推导
const b = first(['x', 'y']);       // b 是 string
// a.toUpperCase();                // 错误:number 没有 toUpperCase

关键认知:泛型不是"放任何类型",而是"占位符,具体什么类型在调用时确定"。一旦确定,后续就严格按那个类型检查。

泛型约束:用 extends 限定能接受什么

有时候你希望泛型"任意类型,但必须有某种属性"。用 extends 加约束:

// 错:T 可能是 number 这种没有 .length 的类型
function logLength<T>(x: T) { console.log(x.length); }

// 对:约束 T 必须有 length 属性
function logLength<T extends { length: number }>(x: T): T {
    console.log(x.length);
    return x;
}
logLength('hello');     // OK
logLength([1, 2, 3]);   // OK
// logLength(42);       // 错:number 没有 length

keyof 与索引访问:类型层面的"键值对"

JavaScript 里 Object.keys(obj) 返回字符串数组,但 TypeScript 在类型层面有更精细的 keyof —— 它返回对象类型所有键的字面量联合类型

type User = { id: number; name: string; age: number };

type K = keyof User;   // 'id' | 'name' | 'age'

// 索引访问类型:用类型做"下标"
type IdType = User['id'];    // number
type StrField = User['name' | 'age'];  // string | number

keyof + 泛型组合出最实用的一个工具函数 —— 类型安全的属性获取:

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const u: User = { id: 1, name: 'mores', age: 30 };
const name = getProp(u, 'name');  // 类型推导为 string
const age  = getProp(u, 'age');   // 类型推导为 number
// getProp(u, 'email');           // 错:'email' 不是 keyof User

这个 8 行函数包含了"泛型 + 约束 + 索引访问"三个核心概念,且类型推导精确到具体属性的类型 —— 这是 any 永远做不到的。

映射类型:从一个类型变出另一个

映射类型让你"批量"修改对象类型的所有属性。基础语法:

// 把 User 的每个属性变成可选
type PartialUser = { [K in keyof User]?: User[K] };

// 把 User 的每个属性变成 readonly
type ReadonlyUser = { readonly [K in keyof User]: User[K] };

// 把 User 的每个属性的类型变成 string
type StringifiedUser = { [K in keyof User]: string };

TS 内置了几个常用映射类型,几乎天天用:

Partial<T>    // 所有属性变可选
Required<T>   // 所有属性变必选
Readonly<T>   // 所有属性变 readonly
Pick<T, K>    // 只挑选 K 中的属性
Omit<T, K>    // 排除 K 中的属性
Record<K, V>  // 构造一个键为 K、值为 V 的对象类型

type UserPreview = Pick<User, 'id' | 'name'>;     // { id, name }
type UserWithoutId = Omit<User, 'id'>;            // { name, age }
type RoleMap = Record<'admin' | 'user', string>;  // { admin, user: string }

条件类型:类型层面的 if

条件类型让类型可以"根据另一个类型的形态"分支选择,语法是 T extends U ? X : Y

type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

// 实用例子:NonNullable 排除 null 和 undefined
type NotNull<T> = T extends null | undefined ? never : T;
type X = NotNull<string | null>;   // string

分发条件类型(Distributive Conditional Types)

有个隐藏规则:当条件类型的检查类型是"裸"的类型参数时,联合类型会被自动"分发"

type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;
// 不是 (string | number)[],而是 string[] | number[]
// 因为 T extends any 分发了:ToArray<string> | ToArray<number>

想阻止分发,用方括号包起来:

type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayNoDist<string | number>;   // (string | number)[]

infer:在条件类型里"取出"一个类型

infer 让你在条件类型的 extends 子句里声明一个"待推导"的类型变量,然后在分支里使用它。这是 TypeScript 类型编程最强大的工具。

// 提取函数的返回值类型
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;

type F = (x: number) => string;
type R = ReturnTypeOf<F>;   // string

// 提取函数的第一个参数类型
type FirstArg<T> = T extends (arg: infer A, ...rest: any) => any ? A : never;

// 提取数组的元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<number[]>;   // number

// 提取 Promise 的 resolve 类型
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type U = Unwrap<Promise<string>>;   // string

TS 标准库的 ReturnTypeParametersAwaited 都是这种套路写出来的。

实战 1:类型安全的事件系统

泛型 + 索引访问可以让事件总线在编译期就检查"事件名"和"参数类型"是否匹配。

type EventMap = {
    login:  { userId: number; at: Date };
    logout: { userId: number };
    error:  { code: string; message: string };
};

class EventBus<M extends Record<string, any>> {
    private handlers: { [K in keyof M]?: Array<(payload: M[K]) => void> } = {};

    on<K extends keyof M>(event: K, handler: (payload: M[K]) => void) {
        (this.handlers[event] ??= []).push(handler);
    }

    emit<K extends keyof M>(event: K, payload: M[K]) {
        this.handlers[event]?.forEach((h) => h(payload));
    }
}

const bus = new EventBus<EventMap>();
bus.on('login', (p) => console.log(p.userId, p.at));  // p 推导为 { userId, at }
bus.emit('login', { userId: 1, at: new Date() });       // OK
// bus.emit('login', { userId: 1 });                    // 错:缺 at
// bus.emit('unknown', {});                             // 错:事件名不存在

注意 on 里写 handler 时,参数 p 不用手动注解,IDE 自动推导为对应事件的 payload 类型。这种类型驱动的开发体验,就是 TypeScript 的卖点。

实战 2:深度只读 / 深度可选

内置 Readonly / Partial 只是浅一层。要做深的,自己写递归类型:

type DeepReadonly<T> = {
    readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type DeepPartial<T> = {
    [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

interface Config {
    server: { host: string; port: number };
    log: { level: 'info' | 'error' };
}

type ConfigPatch = DeepPartial<Config>;
// 等价于 { server?: { host?, port? }, log?: { level? } }
const patch: ConfigPatch = { server: { port: 8080 } };  // 合法

实战 3:模板字面量类型

TS 4.1 引入的模板字面量类型,能在类型层面拼接字符串字面量。它让"路由路径、CSS 类名、SQL 字段"这类字符串规约在编译期就被校验。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type Route = `${HttpMethod} ${Endpoint}`;
//   等于 'GET /api/...' | 'POST /api/...' | ...

const r1: Route = 'GET /api/users';     // OK
// const r2: Route = 'PATCH /api/users'; // 错

// 从对象属性名生成 getter 名
type Getter<T> = {
    [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type UserGetter = Getter<User>;
// { getId(): number; getName(): string; getAge(): number }

类型缩窄:from any to safe

TypeScript 的类型系统支持"控制流分析" —— 在 if / switch / typeof / instanceof / in 等检查后,编译器会自动缩窄类型。

function area(s: { kind: 'circle'; r: number } | { kind: 'square'; side: number }) {
    if (s.kind === 'circle') {
        return Math.PI * s.r ** 2;   // s 被缩窄为 circle 分支
    }
    return s.side ** 2;              // 这里 s 必然是 square
}

// 自定义类型谓词
function isString(x: unknown): x is string {
    return typeof x === 'string';
}

function format(x: unknown) {
    if (isString(x)) {
        return x.toUpperCase();      // 缩窄为 string
    }
    return String(x);
}

常见坑

坑 1:any 是病毒,unknown 是良药。 any 会让类型检查整条链路失效。需要"未知类型"时用 unknown,它强迫你在使用前做类型缩窄。

坑 2:对象字面量的"多余属性检查"。 直接传字面量时 TS 会额外检查多余属性,赋给变量后再传就不会。

type Opt = { a: number };
// fn({ a: 1, b: 2 });  // 错:b 是多余属性
const o = { a: 1, b: 2 };
fn(o);                   // 不报错(只检查鸭子类型)

坑 3:函数参数的双向协变。strictFunctionTypes 开启前,函数参数是双向协变的,容易出"意外能赋值"的情况。务必在 tsconfig 里开 strict: true

类型守卫与判别联合:把运行时分支和类型挂钩

在编写业务代码时最有价值的不是炫技的高级类型,而是判别联合(discriminated union)—— 用一个公共字段把多种"形态"区分开,TS 能根据这个字段自动缩窄。

type Response =
    | { status: 'loading' }
    | { status: 'success'; data: User[] }
    | { status: 'error'; message: string };

function render(r: Response) {
    switch (r.status) {
        case 'loading': return '加载中';
        case 'success': return `共 ${r.data.length} 个用户`;     // 这里 r.data 类型确定
        case 'error':   return `出错:${r.message}`;
    }
}

把它和 never 结合起来,还能做"穷尽性检查"—— 如果你以后加了新的 status 但忘记加 case,编译器会报错:

function assertNever(x: never): never { throw new Error('unreachable'); }

function render2(r: Response) {
    switch (r.status) {
        case 'loading': return '加载中';
        case 'success': return `共 ${r.data.length}`;
        case 'error':   return `出错:${r.message}`;
        default: return assertNever(r);   // 漏写 case,这里就编译失败
    }
}

const 断言:把字面量类型保住

TS 默认会"扩展"字面量类型 —— const x = 'a' 推导成 'a',但 const obj = { x: 'a' } 推导成 { x: string }。这在写配置和枚举时很烦人。as const 解决这个问题。

const routes = ['home', 'about', 'contact'] as const;
// 类型是 readonly ['home', 'about', 'contact'],不是 string[]

type Route = typeof routes[number];
// 'home' | 'about' | 'contact'

const config = {
    method: 'GET',
    cache: 'no-store',
} as const;
// 所有字段都是字面量类型,适合作为联合类型的来源

实战:把字符串路由变成类型安全

用模板字面量类型 + infer 解析路由路径里的参数:

type ExtractParams<Path extends string> =
    Path extends `${string}:${infer Param}/${infer Rest}`
        ? { [K in Param | keyof ExtractParams<Rest>]: string }
        : Path extends `${string}:${infer Param}`
        ? { [K in Param]: string }
        : {};

type P1 = ExtractParams<'/users/:id'>;            // { id: string }
type P2 = ExtractParams<'/users/:uid/posts/:pid'>; // { uid: string; pid: string }

function navigate<P extends string>(path: P, params: ExtractParams<P>) {
    // 强制传的参数必须和 path 里的占位符匹配
}

navigate('/users/:id', { id: '42' });                     // OK
// navigate('/users/:id', {});                            // 错
// navigate('/users/:id/posts/:pid', { id: 'x' });        // 错:缺 pid

这类技巧让"接口契约"从文档/约定升级为编译期检查。后端再改路由格式,所有调用方第一时间在 IDE 里飘红 —— 这就是类型驱动开发的真正价值。

写在最后

把 TypeScript 类型系统看成一门"在编译期跑的、纯函数式的小语言":泛型是函数,keyof / 索引访问是基本运算,条件类型是 if,infer 是 let,映射类型是 map,模板字面量类型是字符串拼接。掌握了这套类比,看到再复杂的类型表达式都能拆解。

工程上的判断标准:能让一个错误从"运行时崩溃"提前到"红线下波浪号",就是值得的;为了炫技把类型写得难以读懂,就是有害的。在团队代码里,优先用内置 Partial / Pick / Omit 等组合,避免堆叠超过三层的条件类型 —— 类型本身也是你要维护的代码。

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

Rust 所有权与借用完全指南:从内存安全到生命周期标注

2026-5-15 10:55:54

技术教程

React Hooks 原理与陷阱:从 useState 到自定义 Hook

2026-5-15 10:55:54

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索