☰
TypeScript 2.1 特性全解析:keyof 查找类型、映射类型与工程化能力升级
2026/9/29 7:48:33 网站建设 项目流程
  • 文档
  • 教程

【免费下载链接】TypeScript

TypeScript 使用手册(中文版)翻译。http://www.typescriptlang.org

项目地址:https://gitcode.com/gh_mirrors/typ/TypeScript
点击查看免费下载

TypeScript 2.1 是类型系统走向成熟的关键版本:它首次引入keyof索引类型查询与查找类型,正式确立映射类型语法,并将Partial、Readonly、Record、Pick纳入标准库;同时带来对象扩展/rest 运算符、ES3/ES5 环境下的异步函数、tslib外部辅助库、配置继承(extends)等大量工程化改进。本文以 TypeScript 使用手册中文版的《TypeScript 2.1》发布说明为骨架,结合仓库内的实用工具类型、高级类型、映射类型(手册 v2)、tsconfig.json 配置与破坏性改动等配套文档,逐一展开每个新特性的语法、原理与实战用法,帮助你理解这些至今仍被广泛使用的类型工具背后的设计动机。

keyof与查找类型:把属性名变成类型

在 JavaScript 中,以属性名称作为参数的 API 相当普遍(如get、set、pick、omit这类工具函数),但在此之前,TypeScript 一直缺乏一种方式来表达"属性名与属性值之间的类型关系"。

TypeScript 2.1 为此引入了索引类型查询(indexed type query),即keyof。keyof T产生的类型是T的全部属性名组成的联合类型,且被认为是string的子类型:

interface Person { name: string; age: number; location: string; } type K1 = keyof Person; // "name" | "age" | "location" type K2 = keyof Person[]; // "length" | "push" | "pop" | "concat" | ... type K3 = keyof { [x: string]: Person }; // string

从示例可见:对普通接口取keyof得到属性名字面量联合;对数组取keyof得到的是数组的内置方法名(如push、pop、concat);对具有字符串索引签名的对象取keyof则退化为string。这一特性与高级类型中介绍的索引签名、联合类型等机制共同构成了现代类型操作的基础。

与之相对应的是索引访问类型(indexed access type),也称查找类型(lookup type)。语法上它看起来像元素访问,但出现在类型位置上:

type P1 = Person['name']; // string type P2 = Person['name' | 'age']; // string | number type P3 = string['charAt']; // (pos: number) => string type P4 = string[]['push']; // (...items: string[]) => number type P5 = string[][0]; // string

索引访问支持传入单个字面量(Person['name'])、字面量联合(Person['name' | 'age'])、方法名(string['charAt'])甚至数字索引(string[][0]),结果是对应成员的类型。

将keyof与查找类型组合到泛型约束中,就能写出真正类型安全的属性读写函数:

function getProperty<T, K extends keyof T>(obj: T, key: K) { return obj[key]; // 推断类型是 T[K] } function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]) { obj[key] = value; } let x = { foo: 10, bar: 'hello!' }; let foo = getProperty(x, 'foo'); // number let bar = getProperty(x, 'bar'); // string let oops = getProperty(x, 'wargarbl'); // 错误!"wargarbl" 不存在于 "foo" | "bar" 中 setProperty(x, 'foo', 'string'); // 错误!属性 'foo' 的类型是 number 而非 string

这里K extends keyof T保证key一定是T的合法属性名,而返回值/写入值类型被精确收窄为T[K]——传入'foo'时返回number,传入'bar'时返回string,传错属性名或值类型不匹配都会在编译期直接报错。这套模式正是后来pick、omit等工具类型与Object.keys类型安全封装的理论基础。

映射类型:批量变换类型的属性

一个常见的需求是:基于现有类型生成"每个属性都可选"的新类型。假设有Person:

interface Person { name: string; age: number; location: string; }

手动写出可选版本既冗长又难以与源类型保持同步:

interface PartialPerson { name?: string; age?: number; location?: string; }

映射类型(mapped type)将这个过程变成对Person的一次广义变换:它通过遍历字面量类型的集合(通常是keyof T),为新的对象类型计算出一组属性。可以类比 Python 中的列表推导式——只是它生成的不是列表元素,而是类型属性:

type Partial<T> = { [P in keyof T]?: T[P]; }; type PartialPerson = Partial<Person>;

语法[P in keyof T]?: T[P]的含义是:对T的每个属性名P,生成同名属性,值类型为T[P],并统一加上可选标记?。除Partial外,映射类型可以表达许多有用的类型变换:

// 保持类型相同,但每个属性是只读的。 type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // 相同的属性名称,但使值是一个 Promise,而不是一个具体的值 type Deferred<T> = { [P in keyof T]: Promise<T[P]>; }; // 为 T 的属性添加代理 type Proxify<T> = { [P in keyof T]: { get(): T[P]; set(v: T[P]): void }; };

Readonly<T>为每个属性加readonly;Deferred<T>把每个属性值包装成Promise(非常适合异步加载场景);Proxify<T>则为每个属性生成一对get/set访问器签名(适合代理模式封装)。后续版本在映射类型上又加入了readonly、?修饰符的+/-增删语法(详见映射类型(手册 v2)),其根源正是 2.1 确立的这套[P in keyof T]机制。

Partial、Readonly、Record与Pick:首批标准库工具类型

Partial和Readonly因为足够通用,从 TypeScript 2.1 起默认包含在标准库(lib)中,全局可见、无需引入。它们可以描述常见的 JS 运行时行为:

function assign<T>(obj: T, props: Partial<T>): void; function freeze<T>(obj: T): Readonly<T>;

assign用Partial<T>表达"只允许传入obj已有属性的子集",freeze用Readonly<T>表达"冻结后所有属性不可写"——这正是Object.freeze的类型签名。关于这四个工具类型的完整定义与更多示例,可参阅仓库中的实用工具类型一章(其中Partial、Readonly、Record、Pick均标注为 TypeScript 2.1 引入)。

2.1 同时带来了另外两个工具类型:Record与Pick。

Pick从T中选取一组属性K构造新类型:

// 从 T 中选取一组属性 K declare function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>; const nameAndAgeOnly = pick(person, 'name', 'age'); // { name: string, age: number }

Record则以一组键K为属性名、以T为值类型构造对象类型,常与keyof和映射一起配合使用:

// 对于类型 T 的每个属性 K,将其转换为 U function mapObject<K extends string | number, T, U>( obj: Record<K, T>, f: (x: T) => U ): Record<K, U>; const names = { foo: 'hello', bar: 'world', baz: 'bye' }; const lengths = mapObject(names, s => s.length); // { foo: number, bar: number, baz: number }

Record<K, T>要求K必须是string | number的子类型,从而保证键的合法性;mapObject借助它实现了"逐属性映射值类型"的类型安全版本。Record的典型用法还包括以字面量联合为键构造"枚举字典",例如type Page = 'home' | 'about' | 'contact'搭配Record<Page, PageInfo>(见实用工具类型)。

对象扩展运算符与 rest 运算符

TypeScript 2.1 带来了 ES.next 提案中对象**扩展运算符(spread)**与rest 运算符的支持。

类似于数组展开,展开对象可以方便地得到浅拷贝:

let copy = { ...original };

也可以一次性合并多个对象——以下merged将具有来自foo、bar和baz的全部属性:

let merged = { ...foo, ...bar, ...baz };

还可以在展开的同时重写已有属性、添加新属性:

let obj = { x: 1, y: 'string' }; var newObj = { ...obj, z: 3, y: 4 }; // { x: number, y: number, z: number }

这里y从string被覆盖为number,z是新增属性。展开操作的书写顺序决定了最终结果对象中的属性归属:同名属性,靠后的展开(或显式字段)会"覆盖"靠前的。

与对象扩展相对的是对象 rest 运算符,用于在解构时提取剩余属性:

let obj = { x: 1, y: 1, z: 1 }; let { z, ...obj1 } = obj; obj1; // {x: number, y: number};

z被单独解构出去,obj1则收集了其余属性。对象 rest/spread 在 React 组件的 props 透传、Immutable 式状态更新等场景中极为常用;其类型层面的约束(重写属性后的联合结果类型)由编译器自动推导。

低版本异步函数:async/await 下沉到 ES3 与 ES5

async/await在 TypeScript 2.1 之前就已支持,但只能编译为 ES6/ES2015 目标。TypeScript 2.1 使其可以在 ES3 与 ES5 运行时上使用,意味着无论目标环境多么老旧,都可以安全使用异步语法。

使用前需要注意两点:

  1. 运行时必须提供全局 ECMAScript 兼容的Promise——可能需要引入Promise的 polyfill(如es6-promise),或依赖运行时自带的实现;
  2. 通过lib编译参数告知 TypeScriptPromise可用,例如"dom", "es2015"或"dom", "es2015.promise", "es5"。

完整的配置与示例:

tsconfig.json

{ "compilerOptions": { "lib": ["dom", "es2015.promise", "es5"] } }

dramaticWelcome.ts

function delay(milliseconds: number) { return new Promise<void>(resolve => { setTimeout(resolve, milliseconds); }); } async function dramaticWelcome() { console.log('Hello'); for (let i = 0; i < 3; i++) { await delay(500); console.log('.'); } console.log('World!'); } dramaticWelcome();

lib数组里es2015.promise只引入Promise相关声明而不引入整套 ES2015 库,配合es5即可让编译器在 ES5 目标下正确认识Promise类型。编译后在 ES3/ES5 引擎上运行,应能看到"Hello"、间隔出现的三个"."、最后"World!"的正确输出顺序——编译器会生成基于状态机的降级代码,而不是原样保留await。

支持外部辅助库(tslib):告别重复注入

TypeScript 编译器会向输出文件注入若干辅助函数(helpers),例如类继承用的__extends、JSX/对象展开用的__assign、异步函数用的__awaiter。2.1 之前只有两种选择,各有痛点:

  1. 在每一个需要辅助库的文件中都注入一份辅助函数——导致每个输出文件都携带重复代码,对在意包体积的库作者而言是明显负担;
  2. 使用--noEmitHelpers完全不生成辅助函数——代码虽小,但客户必须自行维护这些辅助函数。

TypeScript 2.1 引入了第三种方案:把辅助库作为单独模块一次性加入项目,编译器按需require导入。步骤如下。

首先安装tslib:

npm install tslib

然后使用--importHelpers编译:

tsc --module commonjs --importHelpers a.ts

对于如下输入:

export const o = { a: 1, name: 'o' }; export const copy = { ...o };

生成的.js文件将包含对tslib的导入,并用__assign辅助函数替代内联的对象展开:

'use strict'; var tslib_1 = require('tslib'); exports.o = { a: 1, name: 'o' }; exports.copy = tslib_1.__assign({}, exports.o);

这样,辅助代码在整个项目中只存在一份(tslib模块内),每个输出文件仅多一行require,包尺寸与可维护性同时得到改善。

无类型导入:允许导入没有.d.ts的 JavaScript 模块

TypeScript 历来对模块导入相当严格,这是为了避免拼写错误并防止用户误用模块。但实际项目中经常需要导入那些**没有类型声明(.d.ts)**的既有 JavaScript 模块——在 2.1 之前这会直接报错。

从 TypeScript 2.1 起,你可以导入 JavaScript 模块而不需要类型声明:

// 只要 node_modules/asdf/index.js 存在即可通过编译 import { x } from 'asdf';

需要注意的优先级与限制:

  • 如果类型声明存在——无论是declare module "foo" { ... }这样的环境模块声明,还是node_modules/@types/foo下的声明文件——它们仍然优先被采用;
  • 对于完全没有声明文件的模块导入,若启用了--noImplicitAny,仍会被标记为错误(因为导入的名字x隐式为any)。

也就是说,2.1 放宽的是"无声明也可导入"的边界,而严格性开关--noImplicitAny依然生效。关于模块解析如何逐级查找node_modules、@types以及package.json的"types"字段,可进一步阅读模块解析一章。

新增编译目标:--target ES2016、--target ES2017与--target ESNext

TypeScript 2.1 为tsc增加了三个新的编译目标取值:

  • --target ES2016:指示编译器不要降级编译ES2016 特有的特性,例如**幂运算符;
  • --target ES2017:指示编译器不要降级 ES2017 特有的特性,例如async/await;
  • --target ESNext:对应最新的 TC39 ES 提议特性支持,让编译器紧跟提案演进。

这三个目标让不同代码库可以按各自的运行环境选择"降级到哪一代语法",例如目标环境已原生支持幂运算符或异步函数时,就无需再付出额外的降级代码体积。

改进的any类型推断:基于后续赋值动态收窄

在 2.1 之前,如果 TypeScript 无法确定变量类型,就会直接选择any:

let x; // 隐式 'any' let y = []; // 隐式 'any[]' let z: any; // 显式 'any'.

TypeScript 2.1 改变了这一行为:不再简单地给变量标为any,而是基于后续赋值持续推断并更新类型。注意,此行为仅在设置了--noImplicitAny编译参数时才启用。

let x; // 你仍然可以给 'x' 赋任何你需要的值。 x = () => 42; // 在刚赋值后,TypeScript 2.1 知道 'x' 的类型是 '() => number'。 let y = x(); // 感谢,现在它会告诉你,你不能把一个数字加到一个函数上! console.log(x + y); // ~~~~~ // 错误!运算符 '+' 不能应用于类型 '() => number' 和 'number'。 // TypeScript 仍然允许你给 'x' 赋任何你需要的值。 x = 'Hello world!'; // 并且现在它也知道 'x' 是 'string' 类型的! x.toLowerCase();

x先是函数类型,之后被重新赋值为字符串,编译器随之把x的类型更新为string,于是x.toLowerCase()可以正常调用,而x + y这类非法运算会在编译期报错。

空数组也得到同样的动态跟踪:没有类型注解且初始值为[]的变量被视为隐式any[],并会根据x.push(value)、x.unshift(value)、x[n] = value等操作加入的元素不断演化自身类型:

function f1() { let x = []; x.push(5); x[1] = 'hello'; x.unshift(true); return x; // (string | number | boolean)[] } function f2() { let x = null; if (cond()) { x = []; while (cond()) { x.push('hello'); } } return x; // string[] | null }

f1中依次压入number、string、boolean,返回类型被推导为三者联合的数组;f2中x在null与string[]之间演化,返回类型为string[] | null——即使没有写任何类型注解,编译器也能给出相当精确的结果。

隐式 any 错误变少了

这一改进的额外红利是:启用--noImplicitAny时,你会看到更少的隐式any错误。隐式any错误只会在编译器确实无法确定一个无类型注解变量的类型时才报告:

function f3() { let x = []; // 错误:当变量 'x' 类型无法确定时,它隐式具有 'any[]' 类型。 x.push(5); function g() { x; // 错误:变量 'x' 隐式具有 'any[]' 类型。 } }

f3中x的演化信息没有被充分建立(且被闭包g引用),编译器无法确定类型,于是仍然报告隐式any[]错误——这正是"动态推断"与"严格检查"并存的设计。

更好的字面量类型推断

字符串、数字、布尔字面量类型(如"abc"、1、true)此前仅在存在显式类型注解时才会被推断出来。从 TypeScript 2.1 起,字面量类型总是按规则推断:

  • 不带类型注解的const变量或readonly属性:推断为字面量初始化类型本身;
  • 已初始化且不带类型注解的let变量、var变量、形参或非readonly属性:推断为初始值的扩展字面量类型——字符串字面量扩展为string,数字字面量扩展为number,true/false扩展为boolean,枚举字面量扩展为枚举类型。
const c1 = 1; // Type 1 const c2 = c1; // Type 1 const c3 = 'abc'; // Type "abc" const c4 = true; // Type true const c5 = cond ? 1 : 'abc'; // Type 1 | "abc" let v1 = 1; // Type number let v2 = c2; // Type number let v3 = c3; // Type string let v4 = c4; // Type boolean let v5 = c5; // Type number | string

注意c2 = c1仍是字面量1(const 传播),而v2 = c2则扩展为number。

字面量类型的扩展还可以通过显式类型注解来控制:当一个无注解的const局部变量的初始化表达式被推断为字面量类型时,从它赋值的var变量获得"扩展"字面量类型;而当const局部变量带显式字面量类型注解时,从它赋值的var变量获得非扩展字面量类型:

const c1 = 'hello'; // Widening type "hello" let v1 = c1; // Type string const c2: 'hello' = 'hello'; // Type "hello" let v2 = c2; // Type "hello"

需要留意的是,这一变化也带来一处破坏性改动:const变量和readonly属性默认推断为字面量类型后,===/!==等比较可能产生更严格的结果——例如const DEBUG = true现在类型是true而非boolean,DEBUG === false会直接报"运算符 '===' 不能应用于 'true' 和 'false'";如需刻意放宽,可用const DEBUG = <boolean>true显式声明为boolean。详见破坏性改动:TypeScript 2.1。

将基类构造函数的返回值作为this

在 ES2015 语义中,如果构造函数返回一个对象,该返回值会隐式替换调用方(super()的调用者)的this。TypeScript 2.1 遵循这一语义:捕获任何可能的super()返回值并替换为this。此更改让"使用自定义元素(Custom Elements)"成为可能——浏览器分配的元素可以由用户编写的构造函数来初始化。

class Base { x: number; constructor() { // 返回一个除 "this" 之外的新对象 return { x: 1, }; } } class Derived extends Base { constructor() { super(); this.x = 2; } }

生成的 ES5 代码:

var Derived = (function (_super) { __extends(Derived, _super); function Derived() { var _this = _super.call(this) || this; _this.x = 2; return _this; } return Derived; })(Base);

可以看到_super.call(this)的返回值被存入局部变量_this,构造函数体内对this的访问全部改写为_this,并且构造函数显式返回_this以保证继承链正确。配套说明见破坏性改动:TypeScript 2.1:这条语义变更同时意味着继承内置类(Error、Array、Map等)在 ES5 目标下可能不再可靠工作——因为这些内置构造函数的原型链依赖 ES6 的new.target调整,而 ES5 调用方式无法保证new.target。典型后果是子类实例上的方法可能为undefined、instanceof判断失效((new FooError()) instanceof FooError返回false)。官方推荐的规避方式是在super(...)之后手动修复原型:

class FooError extends Error { constructor(m: string) { super(m); // Set the prototype explicitly. Object.setPrototypeOf(this, FooError.prototype); } sayHello() { return 'hello ' + this.message; } }

不支持Object.setPrototypeOf的运行时可退而使用__proto__。这一节与发布说明正文形成互补:前者解释"为什么生成代码变成这样",后者给出迁移与修复建议。

配置继承:用extends复用tsconfig.json

大型项目常常需要多份构建配置(ES5 与 ES2015、调试与生产、CommonJS 与 System 模块等),而它们之间往往只有少数几个选项不同,维护多份几乎重复的tsconfig.json非常麻烦。TypeScript 2.1 为此引入了extends配置继承,规则如下:

  • extends是tsconfig.json中新的顶级属性(与compilerOptions、files、include、exclude并列);
  • extends的值是一个字符串,指向要继承的其它tsconfig.json的路径(可省略.json扩展名);
  • 先加载被继承(基础)文件中的配置,再由继承文件中的配置重写;
  • 如果发现循环继承,编译器报告错误;
  • 继承配置中的files、include、exclude会覆盖基础配置中对应的值(而非合并);
  • 配置文件中出现的所有相对路径,都相对于它们各自所在的配置文件来解析。

一个典型的继承链示例:

configs/base.json:

{ "compilerOptions": { "allowJs": true, "noImplicitAny": true, "strictNullChecks": true } }

configs/tests.json(测试专用配置,覆盖文件范围与部分编译选项):

{ "compilerOptions": { "preserveConstEnums": true, "stripComments": false, "sourceMaps": true }, "exclude": [ "../tests/baselines", "../tests/scenarios" ], "include": [ "../tests/**/*.ts" ] }

tsconfig.json(主配置继承基础配置):

{ "extends": "./configs/base", "files": [ "main.ts", "supplemental.ts" ] }

tsconfig.nostrictnull.json(针对特殊场景覆盖单个选项):

{ "extends": "./tsconfig", "compilerOptions": { "strictNullChecks": false } }

注意上例中configs/tests.json的include/exclude里../tests/...是相对于configs/目录解析的。这套机制此后成为多环境配置的标准做法,更完整的继承说明与示例可参考tsconfig.json一章中"使用extends继承配置"一节。

新编译参数:--alwaysStrict

--alwaysStrict使编译器:

  1. 以严格模式解析所有代码(即在解析层面启用 ES5 strict mode 语义);
  2. 在每一个生成文件上输出"use strict";指令。

模块代码会自动按严格模式解析,因此该参数主要面向非模块(脚本)代码——如果你希望所有输出文件都显式携带"use strict";,建议加上此选项。

小结

TypeScript 2.1 同时奠定了类型系统的基础设施与工程化能力:keyof/查找类型/映射类型三个机制催生了Partial、Readonly、Record、Pick等至今仍写进无数代码库的标准工具类型;对象 spread/rest 与低版本 async/await 直接改善日常开发体验;tslib、配置继承与--alwaysStrict则让大型项目的构建更可控。若要进一步探索这些特性的现代形态(映射类型修饰符的增删、Omit等后继工具类型),可继续阅读仓库中的实用工具类型、映射类型(手册 v2)与高级类型;升级到 2.1 时需要注意的行为变化,请对照破坏性改动:TypeScript 2.1逐一排查。

  • 文档
  • 教程

【免费下载链接】TypeScript

TypeScript 使用手册(中文版)翻译。http://www.typescriptlang.org

项目地址:https://gitcode.com/gh_mirrors/typ/TypeScript
点击查看免费下载
上一篇:QHotkey多线程使用指南:如何在子线程中安全注册全局热键
下一篇:Floccus众筹成功案例:OpenCollective资金使用报告

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询