TypeScript 高级类型完全指南:泛型、条件类型、映射类型一次吃透

很多人用 TypeScript,停留在"给变量标个类型"的层面 —— 这只用到了它大概 20% 的能力。TS 真正强大的地方,是它有一套图灵完备的类型系统:你可以在"类型"这个层面做计算、做推导、做变换。这篇把泛型、泛型约束、条件类型、infer、映射类型、类型收窄这几个核心武器讲透,最后你会发现 —— TS 那些天天在用的内置工具类型(Partial / Pick / Omit / ReturnType…),全是用这几样基础能力拼出来的,没有任何"魔法"。

泛型:类型也能当"参数"传

泛型是高级类型的地基。它解决的核心问题是:怎么写一个"对多种类型都适用、又不丢失类型信息"的函数或类型。

泛型:类型层面的"参数"

  // 不用泛型:要么 any(丢了类型),要么为每种类型写一遍
  function firstAny(arr: any[]): any { return arr[0]; }

  // 用泛型:T 是一个"类型参数",调用时自动推断
  function first<T>(arr: T[]): T { return arr[0]; }

  first([1, 2, 3]);     // T 推断为 number,返回值类型 number
  first(['a', 'b']);    // T 推断为 string,返回值类型 string

关键体会:<T> 里的 T 是一个"类型参数",它和函数的普通参数本质是一回事 —— 只不过普通参数传的是"值",类型参数传的是"类型"。而且很多时候你不用手写 T 具体是什么,TS 会根据你传入的实参自动推断出来。

泛型的本质,就是"让类型也能像值一样,被传递、被复用"。一旦理解了这个 —— "类型参数" —— 后面所有的高级类型,都是建立在"对类型参数做操作"之上的。

泛型约束:用 extends 划定范围

纯泛型 <T> 有个问题:既然 T 可以是任何类型,那在函数体里,你就不能假设 T 有任何属性 —— 你想访问 x.length,TS 会报错,因为万一调用方传进来的 T 是 number 呢?number 没有 length

解决办法是泛型约束:用 extends 关键字,给类型参数划定一个范围。

泛型约束:用 extends 给类型参数"划范围"

  // T 可以是任何类型 —— 那我们就不能假设它有任何属性
  function logLength<T>(x: T) { console.log(x.length); }  // ✗ 报错

  // 用 extends 约束:T 必须有 length 属性
  function logLength<T extends { length: number }>(x: T) {
    console.log(x.length);   // ✓ OK
    return x;                // 返回值还是精确的 T
  }
  logLength('hello');     // ✓ string 有 length
  logLength([1, 2, 3]);   // ✓ 数组有 length
  logLength(123);         // ✗ number 没有 length,编译期就报错

T extends { length: number } 的意思是:"T 可以是任何类型,但前提是它必须有一个 number 类型的 length 属性"。有了这个约束,函数体里访问 x.length 就合法了,同时返回值还能保持是精确的 T,而不是被泛化成一个模糊的对象类型。

这里有个初学最容易混淆的点必须强调:这个 extends 表示的是"约束 / 符合某个形状",不是面向对象里的"继承"。T extends U 读作"T 符合 U 的形状",而不是"T 继承自 U"。后面条件类型里的 extends 也是这个含义。把这个理解扭过来,TS 的高级类型对你就通了一大半。

条件类型 + infer:类型层面的 if-else 和"提取"

既然 extends 能表示"符合某个形状",那就能用它来做"条件判断"了。条件类型,就是类型层面的三元表达式:

条件类型 + infer:类型层面的"三元表达式"和"提取"

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

  // infer 像一个占位符,让 TS 帮你把某个位置的类型推断出来
  type ElementType<T> = T extends (infer U)[] ? U : T;
  type C = ElementType<number[]>;   // number  —— 提取出了数组元素类型
  type D = ElementType<string>;     // string  —— 不是数组,原样返回

T extends U ? X : Y 读作:"如果 T 符合 U,结果类型就是 X,否则就是 Y"。这就是在"类型"这一层做了一次 if-else 判断。

条件类型真正的威力,在于它能搭配 infer 关键字。infer U 相当于在类型里放了一个"占位符",它告诉 TS:"这个位置的具体类型我不知道,你帮我推断出来,推断的结果存到 U 里,我后面要用。"

上面 ElementType 的例子就是用 infernumber[] 里把"元素类型 number"给"抠"了出来。很多看起来很神奇的工具类型 —— 比如提取函数返回值类型的内置 ReturnType、提取 Promise 内部类型的 Awaited —— 它们的核心,都是"条件类型 + infer"。infer 是从一个复杂类型里"按结构提取出某一部分"的钥匙。

映射类型:批量改造一个类型

前面讲的都是处理"单个类型",映射类型则是用来批量处理一个对象类型的所有属性。它的语法核心是 [K in keyof T] —— 遍历 T 的每一个属性键。

映射类型:批量"改造"一个类型的所有属性

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

  // 把所有属性变可选 —— 这就是内置 Partial<T> 的实现原理
  type MyPartial<T> = { [K in keyof T]?: T[K] };

  // 把所有属性变只读 —— 内置 Readonly<T> 的原理
  type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

拆解一下:keyof T 拿到 T 的所有属性名,组成一个联合类型(比如 "id" | "name" | "age");[K in ...] 就像 for...in 循环一样,遍历这个联合类型里的每一个键。在遍历的过程中,你可以给每个属性加修饰符(? 变可选、readonly 变只读),或者改它的值类型

TS 内置的 Partial<T>(把所有属性变可选)和 Readonly<T>(把所有属性变只读),它们的实现就是上面那两行 —— 没有任何额外的魔法。

组合起来:亲手实现内置工具类型

把泛型、泛型约束、条件类型、infer、映射类型组合在一起,你就能自己实现 TS 的那些内置工具类型了。看几个:

组合起来:亲手实现内置工具类型

  // Pick:从类型里挑几个属性
  type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

  // Exclude:从联合类型里排除某些成员(用了条件类型的"分发"特性)
  type MyExclude<T, U> = T extends U ? never : T;

  // Record:构造一个键值类型
  type MyRecord<K extends keyof any, V> = { [P in K]: V };

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

逐个读一遍:MyPick —— K extends keyof T 约束了 K 只能是 T 里真实存在的属性名(你写错属性名会直接编译报错),然后映射类型把这些属性原样取出。MyExclude —— 用了条件类型作用于联合类型时的"分发"特性:条件类型遇到联合类型 T,会把 T 的每个成员单独拿去做一次判断,符合 U 的变成 never(等于被排除),最后再合起来。MyReturnType —— 条件类型 + infer 的典型应用,把函数的返回值类型"抠"出来。

这一刻你应该有种"原来如此"的踏实感:Partial / Required / Pick / Omit / Record / Exclude / Extract / ReturnType / Parameters / Awaited…… 这些天天在用的工具类型,没有一个是"内置魔法",它们全是用这几个基础能力拼出来的。看懂了它们的实现套路,你就能按同样的方式,写出贴合自己项目需求的类型工具。

三个常被忽略但很基础的工具:keyof / typeof / 索引访问

上面的高级类型,都依赖三个更基础的"取类型"的操作,它们值得单独点出来:

keyof T:拿到类型 T 所有属性名组成的联合类型。前面映射类型里一直在用它。

typeof 变量:注意,这是类型层面typeof(不是 JS 运行时那个)。它能把一个"值"的类型给"取"出来。比如你有一个配置对象 const config = {...},typeof config 就能拿到它的类型,不用你手动再声明一遍。"从值反推类型",是减少重复声明的利器。

索引访问类型 T["key"]:用来取出一个类型里某个属性的类型。比如 User["name"] 就是 string。配合 keyof 还能写 T[keyof T] —— 拿到所有属性值类型的联合。

这三个,加上前面的泛型、条件类型、映射类型,基本就是你"在类型层面编程"的全部基础工具了。它们经常组合出现,比如 typeof config 拿到类型、再 keyof 拿到它的键、再用映射类型改造它。

模板字面量类型:在类型里"拼字符串"

TS 还有一个比较新、也很有意思的能力 —— 模板字面量类型,它能让你在类型层面做字符串拼接。

语法和 JS 的模板字符串很像,但作用在类型上。比如你可以定义一个类型,表示"所有以 on 开头的事件处理器名" —— type EventHandler = `on${string}`,这样 "onClick""onChange" 符合,"click" 不符合。它还能配合联合类型做"组合爆炸":`${"top"|"bottom"}-${"left"|"right"}` 会自动展开成 "top-left" | "top-right" | "bottom-left" | "bottom-right" 四种。

这个能力在做一些"强约束的字符串"时特别有用 —— 比如 CSS-in-JS 的属性名、国际化的 key、API 路由的路径。它让"字符串"也能被类型系统精确地约束起来,而不是简单的 string。一开始可能用不到,但知道有这么个东西,遇到合适场景你就能想起它。

类型收窄与类型守卫:实战里最常用的

前面讲的偏"构造类型",这一节讲的是"类型" —— 类型收窄(narrowing),它是日常写 TS 用得最多、却又最容易被当成理所当然的能力。

类型收窄(narrowing)与类型守卫:

  function format(x: string | number) {
    if (typeof x === 'string') {
      x.toUpperCase();   // 这个分支里,TS 已把 x 收窄成 string
    } else {
      x.toFixed(2);      // 这个分支里,x 被收窄成 number
    }
  }

  // 自定义类型守卫:函数返回 "参数 is 某类型",帮 TS 收窄
  function isCat(pet: Cat | Dog): pet is Cat {
    return (pet as Cat).meow !== undefined;
  }
  if (isCat(pet)) { pet.meow(); }   // 这里 pet 被收窄成 Cat

类型收窄指的是:TS 能根据你代码里的判断逻辑,在不同的代码分支里,把一个"宽泛的类型"收窄成更精确的类型。一个 string | number 的变量,在 typeof x === 'string' 的分支里,TS 就知道它一定是 string,你调 x.toUpperCase() 不会报错。常见的收窄手段有 typeofinstanceofin 操作符、以及对字面量的相等判断。

当内置的收窄手段不够用时(比如要判断的是一个自定义的复杂结构),你可以写自定义类型守卫 —— 一个返回值类型写成 参数 is 某类型 的函数。TS 看到这种函数,就会在 if 里用它的结果来收窄类型。类型守卫是连接"运行时判断"和"编译时类型"的桥梁,实战中处理"这个数据到底是 A 类型还是 B 类型"的场景时,非常有用。

实战建议:别为了炫技而炫技

高级类型很强,但也最容易被滥用。几条实战经验:

优先用内置工具类型。能用 Partial / Pick / Omit / Record 解决的,就别自己手写一长串条件类型。重新发明轮子,既费时又容易出错。

高级类型的价值在"基础设施层"。写通用库、封装 API 请求层、做表单引擎 / 状态管理这种"会被项目里很多地方复用"的代码,值得用复杂类型把约束做扎实 —— 这里的类型投入,会被无数次的使用摊薄。但写一个普通的业务组件,类型简单清晰就好,别上来就堆三层条件类型。

类型不是越复杂越好。一个嵌套了五层条件类型、谁看都头大的类型,维护成本极高。如果一个类型,你三个月后自己都读不懂了,那它就是负债,不是资产。类型的目的是"让代码更安全、更好懂",如果它本身变成了理解负担,就本末倒置了。

善用 IDE 的类型悬浮提示。写复杂类型时,把鼠标悬停在类型上,看 TS 实际推断出来的结果 —— 这是调试类型最快、最直接的方式。类型也是"代码",也需要"调试",而悬浮提示就是你的"打印调试"。

几个常见的坑

坑一:any 一时爽,类型火葬场。遇到类型搞不定就 any,会让 TS 的保护从这个点开始"破洞" —— 而且 any 会像传染病一样扩散。实在要"放弃类型检查",优先用 unknown(它强制你在使用前先收窄),而不是 any

坑二:类型断言 as 是"我跟编译器担保",不是"类型转换"。x as SomeType 不会做任何运行时检查,它只是让你"骗过"编译器。如果你断言错了,运行时照样崩,而且崩得更隐蔽。能用类型收窄就别用 as

坑三:接口(interface)和类型别名(type)分不清。大多数场景两者可以互换。粗略的经验:描述"对象的形状"、且可能需要被继承 / 合并的,用 interface;需要用到联合类型、条件类型、映射类型这些"类型运算"的,用 type。不用太纠结,团队内部统一就行。

坑四:对象字面量的"多余属性检查"。直接把一个字面量赋给一个有类型的变量时,TS 会检查"有没有多余的属性";但如果你先把字面量存到一个变量、再赋值,这个检查就被绕过了。知道有这个机制,能解释一些"为什么这样报错、那样不报错"的困惑。

FAQ

泛型的 <T> 和函数参数,到底什么关系?就是一回事的两个层面。函数参数传"值",在运行时起作用;泛型参数传"类型",在编译时起作用。function f<T>(x: T) 里,Tx 是平行的两个"参数",只是活在不同的世界。

条件类型作用在联合类型上,为什么会"分发"?这是 TS 的一个特意设计:当条件类型 T extends U ? X : YT 是一个"裸的"联合类型时,TS 会把联合的每个成员分别代入判断,最后把结果合起来。Exclude 就是利用了这个特性。如果你不想要分发,可以把 T 用方括号"包"一下来阻止它。

学高级类型,有必要学到能手写复杂工具类型的程度吗?看你的角色。如果你主要写业务,能熟练用泛型 + 内置工具类型 + 类型收窄,就足够应付 90% 的场景了。如果你要写公共库、维护团队的类型基建,那条件类型、infer、映射类型这些就得吃透。按需学,别为了"显得厉害"硬学。

类型体操(type challenges)那种题,值得刷吗?适度刷有好处 —— 它能逼你把条件类型、infer、递归类型这些理解到位。但要清醒:那些题里的很多技巧,在真实项目里用到的频率很低。把它当"健身",而不是"日常",别把项目代码也写成体操。

类型系统的边界:TS 不能做什么

讲了这么多高级类型的强大,也得清醒认识它的边界 —— 知道 TS"不能做什么",和知道它"能做什么"一样重要。

第一,TS 的类型在运行时完全不存在。这是最根本的一条。TypeScript 编译成 JavaScript 后,所有的类型标注、interface、type 全部被"擦除"掉了 —— 运行时只剩下纯 JavaScript。所以你不能在运行时"问"一个值"你是不是某个 interface 类型" —— 那个 interface 早就不存在了。运行时的类型判断,只能靠 JS 本身的手段(typeofinstanceof、检查属性),这也是前面"类型守卫"存在的意义 —— 它就是连接"运行时判断"和"编译时类型"的桥。

第二,TS 不校验"来自外部的数据"。一个接口返回的 JSON、一段用户输入、一个读进来的配置文件 —— 你可以给它们标一个类型,但 TS不会、也无法在运行时去验证"实际收到的数据,真的符合这个类型吗"。如果后端接口偷偷改了字段,TS 在编译期发现不了,你的程序会在运行时拿着一个"类型标注是 A、实际是 B"的数据裸奔。所以,系统边界处(接口、用户输入)的数据,需要用运行时校验库来真正校验,光靠 TS 的类型标注是不够的。

第三,类型标注错了,TS 也只能"信你"。类型断言 as 就是典型 —— 你写 data as User,TS 就真的当它是 User,哪怕它实际上是 null。TS 的安全性,建立在"你给的类型标注是诚实的"这个前提上。一旦你用 asany "骗"了它,它的保护就从那个点开始失效。

认清这三条边界,你才能正确地定位 TS 的价值:它是一个强大的"编译期"工具,帮你在写代码、改代码时,把大量低级错误和不一致挡在编译阶段;但它管不了运行时,管不了外部数据的真实性。一个健壮的程序,是 TS 的编译期类型检查 + 系统边界处的运行时校验,两者配合的结果 —— 别指望 TS 一个人包打天下。

写在最后

TypeScript 的高级类型,本质是一套"在类型层面做编程"的工具,核心就这么几样:

  • 泛型 —— 让类型能像值一样被传递、复用;
  • 泛型约束(extends) —— 给类型参数划范围,表示"符合某个形状";
  • 条件类型(extends ? :)+ infer —— 类型层面的 if-else,以及"按结构提取";
  • 映射类型([K in keyof T]) —— 批量改造一个类型的所有属性;
  • keyof / typeof / 索引访问 —— 在值和类型之间、在类型内部"取类型"的基础工具;
  • 类型收窄 / 类型守卫 —— 日常最常用,把宽泛类型在分支里收窄成精确类型。

这几样吃透,你看 TS 的内置工具类型就像看源码一样清楚,也能写出真正为项目"兜底"的类型约束 —— 让 bug 在编译期就被拦下,而不是跑到线上才暴露。但始终记住那条实战原则:类型是为"安全和清晰"服务的,别让它本身变成负担。

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

React 渲染机制深度解析:搞懂它,你的组件不再无故重渲染

2026-5-14 17:19:06

技术教程

C# async/await 深度解析:异步编程背后到底发生了什么

2026-5-14 17:19:07

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