TypeScript 实用技巧(中)

十四、向类型添加特殊值

原文:exploringjs.com/tackling-ts/ch_special-values.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 14.1?在带内添加特殊值

    • 14.1.1?向类型添加 nullundefined

    • 14.1.2?向类型添加符号

  • 14.2?在带外添加特殊值

    • 14.2.1?辨别式联合

    • 14.2.2?其他类型的联合类型


理解类型的一种方式是将其视为值的集合。有时会有两个级别的值:

  • 基本级别:普通值

  • 元级别:特殊值

在本章中,我们将探讨如何向基本级别类型添加特殊值。

14.1?在带内添加特殊值

添加特殊值的一种方法是创建一个新类型,它是基本类型的超集,其中一些值是特殊的。这些特殊值被称为 哨兵。它们存在于 带内(想象在同一通道内),作为普通值的兄弟。

例如,考虑可读流的以下接口:

interface InputStream {
 getNextLine(): string;
}

目前,.getNextLine() 只处理文本行,而不处理文件结束(EOF)。我们如何为 EOF 添加支持?

可能的方法包括:

  • 在调用 .getNextLine() 之前需要调用一个额外的方法 .isEof()

  • 当到达 EOF 时,.getNextLine() 会抛出异常。

  • EOF 的哨兵值。

接下来的两个小节描述了引入哨兵值的两种方法。

14.1.1?向类型添加 nullundefined

在使用严格的 TypeScript 时,没有简单的对象类型(通过接口、对象模式、类等定义)包括 null。这使得它成为一个可以通过联合类型添加到基本类型 string 的良好哨兵值:

type StreamValue = null | string;

interface InputStream {
 getNextLine(): StreamValue;
}

现在,每当我们使用 .getNextLine() 返回的值时,TypeScript 强制我们考虑两种可能性:字符串和 null,例如:

function countComments(is: InputStream) {
 let commentCount = 0;
 while (true) {
 const line = is.getNextLine();
 // @ts-expect-error: Object is possibly 'null'.(2531)
 if (line.startsWith('#')) { // (A)
 commentCount++;
 }
 if (line === null) break;
 }
 return commentCount;
}

在 A 行,我们不能使用字符串方法 .startsWith(),因为 line 可能是 null。我们可以按照以下方式修复这个问题:

function countComments(is: InputStream) {
 let commentCount = 0;
 while (true) {
 const line = is.getNextLine();
 if (line === null) break;
 if (line.startsWith('#')) { // (A)
 commentCount++;
 }
 }
 return commentCount;
}

现在,当执行到 A 行时,我们可以确信 line 不是 null

14.1.2?向类型添加符号

我们还可以使用除 null 之外的值作为哨兵。符号和对象最适合这个任务,因为它们每个都有唯一的标识,没有其他值可以被误认为它。

这是如何使用符号来表示 EOF 的方法:

const EOF = Symbol('EOF');
type StreamValue = typeof EOF | string;

为什么我们需要 typeof 而不能直接使用 EOF?那是因为 EOF 是一个值,而不是一种类型。类型操作符 typeofEOF 转换为一种类型。有关值和类型的不同语言级别的更多信息,请参见 §7.7 “两种语言级别:动态 vs. 静态”。

14.2?在带外添加特殊值

如果一个方法可能返回 任何 值,我们该怎么办?如何确保基本值和元值不会混淆?这是可能发生的一个例子:

interface InputStream<T> {
 getNextValue(): T;
}

无论我们选择什么值作为 EOF,都存在某人创建 InputStream<typeof EOF> 并将该值添加到流的风险。

解决方法是将普通值和特殊值分开,这样它们就不会混淆。特殊值单独存在被称为 带外(想象不同的通道)。

14.2.1?辨别式联合

辨别式联合 是几个对象类型的联合类型,它们都至少有一个共同的属性,即所谓的 辨别器。辨别器必须对每个对象类型具有不同的值 - 我们可以将其视为对象类型的 ID。

14.2.1.1?示例:InputStreamValue

在下面的例子中,InputStreamValue<T> 是一个辨别联合,其辨别标志是.type

interface NormalValue<T> {
 type: 'normal'; // string literal type
 data: T;
}
interface Eof {
 type: 'eof'; // string literal type
}
type InputStreamValue<T> = Eof | NormalValue<T>;

interface InputStream<T> {
 getNextValue(): InputStreamValue<T>;
}
function countValues<T>(is: InputStream<T>, data: T) {
 let valueCount = 0;
 while (true) {
 // %inferred-type: Eof | NormalValue<T>
 const value = is.getNextValue(); // (A)

 if (value.type === 'eof') break;

 // %inferred-type: NormalValue<T>
 value; // (B)

 if (value.data === data) { // (C)
 valueCount++;
 }
 }
 return valueCount;
}

最初,value 的类型是 InputStreamValue<T>(A 行)。然后我们排除了辨别标志.type的值'eof',它的类型被缩小为NormalValue<T>(B 行)。这就是为什么我们可以在 C 行访问属性.data

14.2.1.2?示例:IteratorResult

在决定如何实现迭代器时,TC39 不想使用固定的哨兵值。否则,该值可能出现在可迭代对象中并破坏代码。一种解决方案是在开始迭代时选择一个哨兵值。相反,TC39 选择了一个带有共同属性.done的辨别联合:

interface IteratorYieldResult<TYield> {
 done?: false; // boolean literal type
 value: TYield;
}

interface IteratorReturnResult<TReturn> {
 done: true; // boolean literal type
 value: TReturn;
}

type IteratorResult<T, TReturn = any> =
 | IteratorYieldResult<T>
 | IteratorReturnResult<TReturn>;
14.2.2?其他类型的联合类型

其他类型的联合类型可以像辨别联合一样方便,只要我们有手段来区分联合的成员类型。

一种可能性是通过唯一属性来区分成员类型:

interface A {
 one: number;
 two: number;
}
interface B {
 three: number;
 four: number;
}
type Union = A | B;

function func(x: Union) {
 // @ts-expect-error: Property 'two' does not exist on type 'Union'.
 // Property 'two' does not exist on type 'B'.(2339)
 console.log(x.two); // error

 if ('one' in x) { // discriminating check
 console.log(x.two); // OK
 }
}

另一种可能性是通过typeof和/或实例检查来区分成员类型:

type Union = [string] | number;

function logHexValue(x: Union) {
 if (Array.isArray(x)) { // discriminating check
 console.log(x[0]); // OK
 } else {
 console.log(x.toString(16)); // OK
 }
}

评论

第四部分:对象、类、数组和函数的类型

原文:exploringjs.com/tackling-ts/pt_types-for-objects-classes-arrays-functions.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:15?对象的类型

十五、对象类型

原文:exploringjs.com/tackling-ts/ch_typing-objects.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 15.1?对象扮演的角色

  • 15.2?对象的类型

  • 15.3?TypeScript 中的Object vs. object

    • 15.3.1?纯 JavaScript:对象 vs. Object的实例

    • 15.3.2?Object(大写“O”)在 TypeScript 中:类Object的实例

    • 15.3.3?TypeScript 中的object(小写“o”):非原始值

    • 15.3.4?Object vs. object:原始值

    • 15.3.5?Object vs. object:不兼容的属性类型

  • 15.4?对象类型文字和接口

    • 15.4.1?对象类型文字和接口之间的区别

    • 15.4.2?接口在 TypeScript 中的结构工作

    • 15.4.3?接口和对象类型文字的成员

    • 15.4.4?方法签名

    • 15.4.5?索引签名:对象作为字典

    • 15.4.6?接口描述Object的实例

    • 15.4.7?多余属性检查:何时允许额外属性?

  • 15.5?类型推断

  • 15.6?接口的其他特性

    • 15.6.1?可选属性

    • 15.6.2?只读属性

  • 15.7?JavaScript 的原型链和 TypeScript 的类型

  • 15.8?本章的来源


在本章中,我们将探讨在 TypeScript 中如何静态地为对象和属性进行类型化。

15.1?对象扮演的角色

在 JavaScript 中,对象可以扮演两种角色(总是至少其中一种,有时混合):

  • 记录具有在开发时已知的固定数量的属性。每个属性可以具有不同的类型。

  • 字典具有任意数量的属性,其名称在开发时未知。所有属性键(字符串和/或符号)具有相同的类型,属性值也是如此。

首先,我们将探索对象作为记录。我们将在本章的后面简要地遇到对象作为字典。

15.2?对象类型

对象有两种不同的一般类型:

  • Object大写“O”是类Object的所有实例的类型:

    let obj1: Object;
    
  • 小写“o”的object是所有非原始值的类型:

    let obj2: object;
    

对象也可以通过它们的属性进行类型化:

// Object type literal
let obj3: {prop: boolean};

// Interface
interface ObjectType {
 prop: boolean;
}
let obj4: ObjectType;

在接下来的章节中,我们将更详细地研究所有这些对象类型的方式。

15.3?TypeScript 中的Object vs. object

15.3.1?纯 JavaScript:对象 vs. Object的实例

在纯 JavaScript 中,有一个重要的区别。

一方面,大多数对象都是Object的实例。

> const obj1 = {};
> obj1 instanceof Object
true

这意味着:

  • Object.prototype在它们的原型链中:

    > Object.prototype.isPrototypeOf(obj1)
    true
    
  • 它们继承了它的属性。

    > obj1.toString === Object.prototype.toString
    true
    

另一方面,我们也可以创建没有Object.prototype的原型链的对象。例如,以下对象根本没有原型:

> const obj2 = Object.create(null);
> Object.getPrototypeOf(obj2)
null

obj2是一个不是类Object实例的对象:

> typeof obj2
'object'
> obj2 instanceof Object
false
15.3.2?TypeScript 中的Object(大写“O”):类Object的实例

请记住,每个类C都创建两个实体:

  • 一个构造函数C

  • 描述构造函数实例的接口C

同样,TypeScript 有两个内置接口:

  • 接口Object指定了Object实例的属性,包括从Object.prototype继承的属性。

  • 接口ObjectConstructor指定了类Object的属性。

这些是接口:

interface Object { // (A)
 constructor: Function;
 toString(): string;
 toLocaleString(): string;
 valueOf(): Object;
 hasOwnProperty(v: PropertyKey): boolean;
 isPrototypeOf(v: Object): boolean;
 propertyIsEnumerable(v: PropertyKey): boolean;
}

interface ObjectConstructor {
 /** Invocation via `new` */
 new(value?: any): Object;
 /** Invocation via function calls */
 (value?: any): any;

 readonly prototype: Object; // (B)

 getPrototypeOf(o: any): any;

 // ···
}
declare var Object: ObjectConstructor; // (C)

观察:

  • 我们既有一个名为Object的变量(行 C),又有一个名为Object的类型(行 A)。

  • Object的直接实例没有自己的属性,因此Object.prototype也匹配Object(行 B)。

15.3.3?TypeScript 中的object(小写“o”):非原始值

在 TypeScript 中,object是所有非原始值的类型(原始值是undefinednull,布尔值,数字,大整数,字符串)。使用此类型,我们无法访问值的任何属性。

15.3.4?TypeScript 中的Object vs. object:原始值

有趣的是,类型Object也匹配原始值:

function func1(x: Object) { }
func1('abc'); // OK

为什么?原始值具有Object所需的所有属性,因为它们继承了Object.prototype

> 'abc'.hasOwnProperty === Object.prototype.hasOwnProperty
true

相反,object不匹配原始值:

function func2(x: object) { }
// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'object'. (2345)
func2('abc');
15.3.5?TypeScript 中的Object vs. object:不兼容的属性类型

使用类型Object时,如果对象具有与接口Object中相应属性冲突的类型,则 TypeScript 会发出警告:

// @ts-expect-error: Type '() => number' is not assignable to
// type '() => string'.
//   Type 'number' is not assignable to type 'string'. (2322)
const obj1: Object = { toString() { return 123 } };

使用类型object时,TypeScript 不会发出警告(因为object不指定任何属性,也不会有任何冲突):

const obj2: object = { toString() { return 123 } };

15.4?对象类型文字和接口

TypeScript 有两种定义非常相似的对象类型的方式:

// Object type literal
type ObjType1 = {
 a: boolean,
 b: number;
 c: string,
};

// Interface
interface ObjType2 {
 a: boolean,
 b: number;
 c: string,
}

我们可以使用分号或逗号作为分隔符。允许并且是可选的尾随分隔符。

15.4.1?对象类型文字和接口之间的区别

在本节中,我们将重点介绍对象类型文字和接口之间最重要的区别。

15.4.1.1?内联

对象类型文字可以内联,而接口不能:

// Inlined object type literal:
function f1(x: {prop: number}) {}

// Referenced interface:
function f2(x: ObjectInterface) {} 
interface ObjectInterface {
 prop: number;
}
15.4.1.2?重复名称

具有重复名称的类型别名是非法的:

// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {first: string};
// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {last: string};

相反,具有重复名称的接口会合并:

interface PersonInterface {
 first: string;
}
interface PersonInterface {
 last: string;
}
const jane: PersonInterface = {
 first: 'Jane',
 last: 'Doe',
};
15.4.1.3?映射类型

对于映射类型(行 A),我们需要使用对象类型文字:

interface Point {
 x: number;
 y: number;
}

type PointCopy1 = {
 [Key in keyof Point]: Point[Key]; // (A)
};

// Syntax error:
// interface PointCopy2 {
//   [Key in keyof Point]: Point[Key];
// };

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 有关映射类型的更多信息

映射类型超出了本书的范围。有关更多信息,请参见TypeScript 手册。

15.4.1.4?多态this类型

多态this类型只能在接口中使用:

interface AddsStrings {
 add(str: string): this;
};

class StringBuilder implements AddsStrings {
 result = '';
 add(str: string) {
 this.result += str;
 return this;
 }
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 本节的来源

  • GitHub 问题“TypeScript:类型 vs. 接口” 由Johannes Ewald

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 **从现在开始,“接口”意味着“接口或对象类型文字”(除非另有说明)。

15.4.2?TypeScript 中的接口是结构化的

接口是结构化的 - 它们不必被实现才能匹配:

interface Point {
 x: number;
 y: number;
}
const point: Point = {x: 1, y: 2}; // OK

有关此主题的更多信息,请参见[content not included]。

15.4.3?接口和对象类型文字的成员

接口和对象类型文字的主体内的构造被称为它们的成员。这些是最常见的成员:

interface ExampleInterface {
 // Property signature
 myProperty: boolean;

 // Method signature
 myMethod(str: string): number;

 // Index signature
 [key: string]: any;

 // Call signature
 (num: number): string;

 // Construct signature
 new(str: string): ExampleInstance; 
}
interface ExampleInstance {}

让我们更详细地看看这些成员:

  • 属性签名定义属性:

    myProperty: boolean;
    
  • 方法签名定义方法:

    myMethod(str: string): number;
    

    注意:参数的名称(在本例中为:str)有助于记录事物的工作原理,但没有其他目的。

  • 需要索引签名来描述用作字典的数组或对象。

    [key: string]: any;
    

    注意:名称 key 仅用于文档目的。

  • 调用签名使接口能够描述函数:

    (num: number): string;
    
  • 构造签名使接口能够描述类和构造函数:

    new(str: string): ExampleInstance; 
    

属性签名应该是不言自明的。调用签名 和 构造签名 将在本书的后面进行描述。接下来我们将更仔细地看一下方法签名和索引签名。

15.4.4?方法签名

就 TypeScript 的类型系统而言,方法定义和属性的值为函数的属性是等效的:

interface HasMethodDef {
 simpleMethod(flag: boolean): void;
}
interface HasFuncProp {
 simpleMethod: (flag: boolean) => void;
}

const objWithMethod: HasMethodDef = {
 simpleMethod(flag: boolean): void {},
};
const objWithMethod2: HasFuncProp = objWithMethod;

const objWithOrdinaryFunction: HasMethodDef = {
 simpleMethod: function (flag: boolean): void {},
};
const objWithOrdinaryFunction2: HasFuncProp = objWithOrdinaryFunction;

const objWithArrowFunction: HasMethodDef = {
 simpleMethod: (flag: boolean): void => {},
};
const objWithArrowFunction2: HasFuncProp = objWithArrowFunction;

我的建议是使用最能表达属性应如何设置的语法。

15.4.5?索引签名:对象作为字典

到目前为止,我们只使用接口来表示具有固定键的对象记录。我们如何表达对象将用作字典的事实?例如:在以下代码片段中,TranslationDict 应该是什么?

function translate(dict: TranslationDict, english: string): string {
 return dict[english];
}

我们使用索引签名(行 A)来表示 TranslationDict 适用于将字符串键映射到字符串值的对象:

interface TranslationDict {
 [key:string]: string; // (A)
}
const dict = {
 'yes': 'sí',
 'no': 'no',
 'maybe': 'tal vez',
};
assert.equal(
 translate(dict, 'maybe'),
 'tal vez');
15.4.5.1?为索引签名键添加类型

索引签名键必须是 stringnumber

  • 不允许使用符号。

  • any 是不允许的。

  • 联合类型(例如 string|number)是不允许的。但是,每个接口可以使用多个索引签名。

15.4.5.2?字符串键 vs. 数字键

与纯 JavaScript 一样,TypeScript 的数字属性键是字符串属性键的子集(参见“JavaScript for impatient programmers”)。因此,如果我们既有字符串索引签名又有数字索引签名,则前者的属性类型必须是后者的超类型。以下示例有效,因为 ObjectRegExp 的超类型:

interface StringAndNumberKeys {
 [key: string]: Object;
 [key: number]: RegExp;
}

// %inferred-type: (x: StringAndNumberKeys) =>
// { str: Object; num: RegExp; }
function f(x: StringAndNumberKeys) {
 return { str: x['abc'], num: x[123] };
}
15.4.5.3?索引签名 vs. 属性签名和方法签名

如果接口中既有索引签名又有属性和/或方法签名,那么索引属性值的类型也必须是属性值和/或方法的超类型。

interface I1 {
 [key: string]: boolean;

 // @ts-expect-error: Property 'myProp' of type 'number' is not assignable
 // to string index type 'boolean'. (2411)
 myProp: number;

 // @ts-expect-error: Property 'myMethod' of type '() => string' is not
 // assignable to string index type 'boolean'. (2411)
 myMethod(): string;
}

相比之下,以下两个接口不会产生错误:

interface I2 {
 [key: string]: number;
 myProp: number;
}

interface I3 {
 [key: string]: () => string;
 myMethod(): string;
}
15.4.6?接口描述 Object 的实例

所有接口描述的对象都是 Object 的实例,并继承 Object.prototype 的属性。

在以下示例中,类型为 {} 的参数 x 与返回类型 Object 兼容:

function f1(x: {}): Object {
 return x;
}

同样,{} 有一个 .toString() 方法:

function f2(x: {}): { toString(): string } {
 return x;
}
15.4.7?多余属性检查:何时允许额外属性?

例如,考虑以下接口:

interface Point {
 x: number;
 y: number;
}

有两种(等等)方式可以解释此接口:

  • 封闭解释:它可以描述所有具有指定类型的属性 .x.y 的对象。换句话说:这些对象不能有多余的属性(超出所需的属性)。

  • 开放解释:它可以描述所有具有至少属性 .x.y 的对象。换句话说:允许多余的属性。

TypeScript 使用两种解释。为了探索它是如何工作的,我们将使用以下函数:

function computeDistance(point: Point) { /*...*/ }

默认情况下,允许多余的属性 .z

const obj = { x: 1, y: 2, z: 3 };
computeDistance(obj); // OK

但是,如果我们直接使用对象文字,则不允许多余的属性:

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance({ x: 1, y: 2, z: 3 }); // error

computeDistance({x: 1, y: 2}); // OK
15.4.7.1?为什么对象文字中禁止多余的属性?

为什么对象文字有更严格的规则?它们可以防止属性键中的拼写错误。我们将使用以下接口来演示这意味着什么。

interface Person {
 first: string;
 middle?: string;
 last: string;
}
function computeFullName(person: Person) { /*...*/ }

属性 .middle 是可选的,可以省略(可选属性在本章后面有介绍)。对于 TypeScript 来说,错误地拼写它的名称看起来像是省略了它并提供了多余的属性。但是,它仍然捕获了拼写错误,因为在这种情况下不允许多余的属性:

// @ts-expect-error: Argument of type '{ first: string; mdidle: string;
// last: string; }' is not assignable to parameter of type 'Person'.
//   Object literal may only specify known properties, but 'mdidle'
//   does not exist in type 'Person'. Did you mean to write 'middle'?
computeFullName({first: 'Jane', mdidle: 'Cecily', last: 'Doe'});
15.4.7.2?如果对象来自其他地方,为什么允许多余的属性?

这个想法是,如果一个对象来自其他地方,我们可以假设它已经经过审查,不会有任何拼写错误。然后我们可以不那么小心。

如果拼写错误不是问题,我们的目标应该是最大限度地提高灵活性。考虑以下函数:

interface HasYear {
 year: number;
}

function getAge(obj: HasYear) {
 const yearNow = new Date().getFullYear();
 return yearNow - obj.year;
}

如果不允许大多数传递给getAge()的值具有多余的属性,那么这个函数的用处将会非常有限。

15.4.7.3?空接口允许多余的属性

如果接口为空(或者使用对象类型文字{}),则始终允许多余的属性:

interface Empty { }
interface OneProp {
 myProp: number;
}

// @ts-expect-error: Type '{ myProp: number; anotherProp: number; }' is not
// assignable to type 'OneProp'.
//   Object literal may only specify known properties, and
//   'anotherProp' does not exist in type 'OneProp'. (2322)
const a: OneProp = { myProp: 1, anotherProp: 2 };
const b: Empty = {myProp: 1, anotherProp: 2}; // OK
15.4.7.4?仅匹配没有属性的对象

如果我们想要强制对象没有属性,我们可以使用以下技巧(来源:Geoff Goodman):

interface WithoutProperties {
 [key: string]: never;
}

// @ts-expect-error: Type 'number' is not assignable to type 'never'. (2322)
const a: WithoutProperties = { prop: 1 };
const b: WithoutProperties = {}; // OK
15.4.7.5?允许对象文字中的多余属性

如果我们想要允许对象文字中的多余属性怎么办?例如,考虑接口Point和函数computeDistance1()

interface Point {
 x: number;
 y: number;
}

function computeDistance1(point: Point) { /*...*/ }

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance1({ x: 1, y: 2, z: 3 });

一种选择是将对象文字分配给一个中间变量:

const obj = { x: 1, y: 2, z: 3 };
computeDistance1(obj);

第二种选择是使用类型断言:

computeDistance1({ x: 1, y: 2, z: 3 } as Point); // OK

第三种选择是重写computeDistance1(),使其使用类型参数:

function computeDistance2<P extends Point>(point: P) { /*...*/ }
computeDistance2({ x: 1, y: 2, z: 3 }); // OK

第四种选择是扩展接口Point,以便允许多余的属性:

interface PointEtc extends Point {
 [key: string]: any;
}
function computeDistance3(point: PointEtc) { /*...*/ }

computeDistance3({ x: 1, y: 2, z: 3 }); // OK

我们将继续讨论两个示例,其中 TypeScript 不允许多余的属性是一个问题。

15.4.7.5.1?允许多余的属性:示例Incrementor

在这个例子中,我们想要实现一个Incrementor,但是 TypeScript 不允许额外的属性.counter

interface Incrementor {
 inc(): void
}
function createIncrementor(start = 0): Incrementor {
 return {
 // @ts-expect-error: Type '{ counter: number; inc(): void; }' is not
 // assignable to type 'Incrementor'.
 //   Object literal may only specify known properties, and
 //   'counter' does not exist in type 'Incrementor'. (2322)
 counter: start,
 inc() {
 // @ts-expect-error: Property 'counter' does not exist on type
 // 'Incrementor'. (2339)
 this.counter++;
 },
 };
}

然而,即使使用类型断言,仍然存在一个类型错误:

function createIncrementor2(start = 0): Incrementor {
 return {
 counter: start,
 inc() {
 // @ts-expect-error: Property 'counter' does not exist on type
 // 'Incrementor'. (2339)
 this.counter++;
 },
 } as Incrementor;
}

我们可以在接口Incrementor中添加索引签名。或者 - 尤其是如果不可能的话 - 我们可以引入一个中间变量:

function createIncrementor3(start = 0): Incrementor {
 const incrementor = {
 counter: start,
 inc() {
 this.counter++;
 },
 };
 return incrementor;
}
15.4.7.5.2?允许多余的属性:示例.dateStr

以下比较函数可用于对具有属性.dateStr的对象进行排序:

function compareDateStrings(
 a: {dateStr: string}, b: {dateStr: string}) {
 if (a.dateStr < b.dateStr) {
 return +1;
 } else if (a.dateStr > b.dateStr) {
 return -1;
 } else {
 return 0;
 }
 }

例如,在单元测试中,我们可能希望直接使用对象文字调用此函数。TypeScript 不允许我们这样做,我们需要使用其中一种解决方法。

15.5?类型推断

这些是 TypeScript 通过各种方式创建的对象推断的类型:

// %inferred-type: Object
const obj1 = new Object();

// %inferred-type: any
const obj2 = Object.create(null);

// %inferred-type: {}
const obj3 = {};

// %inferred-type: { prop: number; }
const obj4 = {prop: 123};

// %inferred-type: object
const obj5 = Reflect.getPrototypeOf({});

原则上,Object.create()的返回类型可以是object。但是,any允许我们添加和更改结果的属性。

15.6?接口的其他特性

15.6.1?可选属性

如果我们在属性名称后面加上问号(?),那么该属性就是可选的。相同的语法用于将函数、方法和构造函数的参数标记为可选。在下面的例子中,属性.middle是可选的:

interface Name {
 first: string;
 middle?: string;
 last: string;
}

因此,省略该属性是可以的(A 行):

const john: Name = {first: 'Doe', last: 'Doe'}; // (A)
const jane: Name = {first: 'Jane', middle: 'Cecily', last: 'Doe'};
15.6.1.1?可选 vs. undefined|string

.prop1.prop2之间有什么区别?

interface Interf {
 prop1?: string;
 prop2: undefined | string; 
}

可选属性可以做undefined|string可以做的一切。我们甚至可以使用前者的值undefined

const obj1: Interf = { prop1: undefined, prop2: undefined };

然而,只有.prop1可以省略:

const obj2: Interf = { prop2: undefined };

// @ts-expect-error: Property 'prop2' is missing in type '{}' but required
// in type 'Interf'. (2741)
const obj3: Interf = { };

诸如undefined|stringnull|string之类的类型在我们想要明确省略时非常有用。当人们看到这样一个明确省略的属性时,他们知道它存在,但已被关闭。

15.6.2?只读属性

在下面的例子中,属性.prop是只读的:

interface MyInterface {
 readonly prop: number;
}

因此,我们可以读取它,但我们不能更改它:

const obj: MyInterface = {
 prop: 1,
};

console.log(obj.prop); // OK

// @ts-expect-error: Cannot assign to 'prop' because it is a read-only
// property. (2540)
obj.prop = 2;

15.7?JavaScript 的原型链和 TypeScript 的类型

TypeScript 不区分自有属性和继承属性。它们都被简单地视为属性。

interface MyInterface {
 toString(): string; // inherited property
 prop: number; // own property
}
const obj: MyInterface = { // OK
 prop: 123,
};

objObject.prototype继承.toString()

这种方法的缺点是,JavaScript 中的一些现象无法通过 TypeScript 的类型系统来描述。好处是类型系统更简单。

15.8?本章的来源

  • TypeScript 手册

  • TypeScript 语言规范

评论

十六、TypeScript 中的类定义

原文:exploringjs.com/tackling-ts/ch_class-definitions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 16.1?速查表:纯 JavaScript 中的类

    • 16.1.1?类的基本成员

    • 16.1.2?修饰符:static

    • 16.1.3?类似修饰符的名称前缀:#(私有)

    • 16.1.4?访问器的修饰符:get(getter)和set(setter)

    • 16.1.5?方法的修饰符:*(生成器)

    • 16.1.6?方法的修饰符:async

    • 16.1.7?计算类成员名称

    • 16.1.8?修饰符的组合

    • 16.1.9?底层原理

    • 16.1.10?纯 JavaScript 中类定义的更多信息

  • 16.2?TypeScript 中的非公共数据槽

    • 16.2.1?私有属性

    • 16.2.2?私有字段

    • 16.2.3?私有属性 vs. 私有字段

    • 16.2.4?受保护的属性

  • 16.3?私有构造函数 (ch_class-definitions.html#private-constructors)

  • 16.4?初始化实例属性

    • 16.4.1?严格的属性初始化

    • 16.4.2?使构造函数参数publicprivateprotected

  • 16.5?抽象类


在本章中,我们将研究 TypeScript 中类定义的工作方式:

  • 首先,我们快速查看纯 JavaScript 中类定义的特性。

  • 然后我们探讨 TypeScript 为此带来了哪些新增内容。

16.1?速查表:纯 JavaScript 中的类

本节是关于纯 JavaScript 中类定义的速查表。

16.1.1?类的基本成员
class OtherClass {}

class MyClass1 extends OtherClass {

 publicInstanceField = 1;

 constructor() {
 super();
 }

 publicPrototypeMethod() {
 return 2;
 }
}

const inst1 = new MyClass1();
assert.equal(inst1.publicInstanceField, 1);
assert.equal(inst1.publicPrototypeMethod(), 2);

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 接下来的部分是关于修饰符的

最后,有一张表显示了修饰符如何组合。

16.1.2?修饰符:static
class MyClass2 {

 static staticPublicField = 1;

 static staticPublicMethod() {
 return 2;
 }
}

assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);
16.1.3?类似修饰符的名称前缀:#(私有)
class MyClass3 {
 #privateField = 1;

 #privateMethod() {
 return 2;
 }

 static accessPrivateMembers() {
 // Private members can only be accessed from inside class definitions
 const inst3 = new MyClass3();
 assert.equal(inst3.#privateField, 1);
 assert.equal(inst3.#privateMethod(), 2);
 }
}
MyClass3.accessPrivateMembers();

JavaScript 警告:

  • 目前对私有方法的支持非常有限。

  • 私有字段有更广泛的支持,但也有限制。

TypeScript 自 3.8 版本以来一直支持私有字段,但目前不支持私有方法。

16.1.4?访问器的修饰符:get(getter)和set(setter)

大致上,访问器是通过访问属性调用的方法。有两种类型的访问器:getter 和 setter。

class MyClass5 {
 #name = 'Rumpelstiltskin';

 /** Prototype getter */
 get name() {
 return this.#name;
 }

 /** Prototype setter */
 set name(value) {
 this.#name = value;
 }
}
const inst5 = new MyClass5();
assert.equal(inst5.name, 'Rumpelstiltskin'); // getter
inst5.name = 'Queen'; // setter
assert.equal(inst5.name, 'Queen'); // getter
16.1.5?方法的修饰符:*(生成器)
class MyClass6 {
 * publicPrototypeGeneratorMethod() {
 yield 'hello';
 yield 'world';
 }
}

const inst6 = new MyClass6();
assert.deepEqual(
 [...inst6.publicPrototypeGeneratorMethod()],
 ['hello', 'world']);
16.1.6?方法的修饰符:async
class MyClass7 {
 async publicPrototypeAsyncMethod() {
 const result = await Promise.resolve('abc');
 return result + result;
 }
}

const inst7 = new MyClass7();
inst7.publicPrototypeAsyncMethod()
 .then(result => assert.equal(result, 'abcabc'));
16.1.7?计算类成员名称
const publicInstanceFieldKey = Symbol('publicInstanceFieldKey');
const publicPrototypeMethodKey = Symbol('publicPrototypeMethodKey');

class MyClass8 {

 [publicInstanceFieldKey] = 1;

 [publicPrototypeMethodKey]() {
 return 2;
 }
}

const inst8 = new MyClass8();
assert.equal(inst8[publicInstanceFieldKey], 1);
assert.equal(inst8[publicPrototypeMethodKey](), 2);

评论:

  • 此功能的主要用例是诸如Symbol.iterator之类的符号。但是任何表达式都可以在方括号内使用。

  • 我们可以计算字段、方法和访问器的名称。

  • 我们无法计算私有成员的名称(这些名称始终是固定的)。

16.1.8?修饰符的组合

字段(没有级别意味着构造存在于实例级别):

级别 可见性
(实例)
(实例) #
static
static #

方法(没有级别表示构造存在于原型级别):

级别 访问器 异步 生成器 可见性
(原型)
(原型) get
(原型) set
(原型) async
(原型) *
(原型) async *
(与原型相关) #
(与原型相关) get #
(与原型相关) set #
(与原型相关) async #
(与原型相关) * #
(与原型相关) async * #
static
static get
static set
static async
static *
static async *
static #
static get #
static set #
static async #
static * #
static async * #

方法的限制:

  • 访问器不能是异步的或生成器。
16.1.9?底层

重要的是要记住,对于类,有两条原型对象链:

  • 以一个实例开始的实例链。

  • 从该实例的类开始的静态链。

考虑以下纯 JavaScript 示例:

class ClassA {
 static staticMthdA() {}
 constructor(instPropA) {
 this.instPropA = instPropA;
 }
 prototypeMthdA() {}
}
class ClassB extends ClassA {
 static staticMthdB() {}
 constructor(instPropA, instPropB) {
 super(instPropA);
 this.instPropB = instPropB;
 }
 prototypeMthdB() {}
}
const instB = new ClassB(0, 1);

图 1 显示了由 ClassAClassB 创建的原型链的样子。

图 1: 和  创建了两条原型链:一条是类的(左侧),一条是实例的(右侧)。)

图 1:ClassAClassB 创建了两条原型链:一条是类的(左侧),一条是实例的(右侧)。

16.1.10?纯 JavaScript 中类定义的更多信息
  • 公共字段、私有字段、私有方法/获取器/设置器(博客文章)

  • 所有剩余的 JavaScript 类特性(“JavaScript for impatient programming”中的章节)

16.2?TypeScript 中的非公共数据槽

在 TypeScript 中,默认情况下,所有数据槽都是公共属性。有两种方法可以保持数据私有:

  • 私有属性

  • 私有字段

我们接下来会看两者。

请注意,TypeScript 目前不支持私有方法。

16.2.1?私有属性

私有属性是 TypeScript 专有的(静态)特性。通过在关键字 private(A 行)前面加上前缀,任何属性都可以被设置为私有的:

class PersonPrivateProperty {
 private name: string; // (A)
 constructor(name: string) {
 this.name = name;
 }
 sayHello() {
 return `Hello ${this.name}!`;
 }
}

现在,如果我们在错误的范围内访问该属性,我们会得到编译时错误(A 行):

const john = new PersonPrivateProperty('John');

assert.equal(
 john.sayHello(), 'Hello John!');

// @ts-expect-error: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'. (2341)
john.name; // (A)

然而,private 在运行时不会改变任何东西。在那里,属性.name 与公共属性无法区分:

assert.deepEqual(
 Object.keys(john),
 ['name']);

当我们查看类编译成的 JavaScript 代码时,我们还可以看到私有属性在运行时不受保护:

class PersonPrivateProperty {
 constructor(name) {
 this.name = name;
 }
 sayHello() {
 return `Hello ${this.name}!`;
 }
}
16.2.2?私有字段

私有字段是 TypeScript 自 3.8 版本以来支持的新 JavaScript 特性:

class PersonPrivateField {
 #name: string;
 constructor(name: string) {
 this.#name = name;
 }
 sayHello() {
 return `Hello ${this.#name}!`;
 }
}

这个版本的 Person 大部分用法与私有属性版本相同:

const john = new PersonPrivateField('John');

assert.equal(
 john.sayHello(), 'Hello John!');

然而,这次,数据完全封装起来了。在类外部使用私有字段语法甚至是 JavaScript 语法错误。这就是为什么我们必须在 A 行使用 eval(),以便我们可以执行这段代码:

assert.throws(
 () => eval('john.#name'), // (A)
 {
 name: 'SyntaxError',
 message: "Private field '#name' must be declared in "
 + "an enclosing class",
 });

assert.deepEqual(
 Object.keys(john),
 []);

编译结果现在更加复杂(稍微简化):

var __classPrivateFieldSet = function (receiver, privateMap, value) {
 if (!privateMap.has(receiver)) {
 throw new TypeError(
 'attempted to set private field on non-instance');
 }
 privateMap.set(receiver, value);
 return value;
};

// Omitted: __classPrivateFieldGet

var _name = new WeakMap();
class Person {
 constructor(name) {
 // Add an entry for this instance to _name
 _name.set(this, void 0);

 // Now we can use the helper function:
 __classPrivateFieldSet(this, _name, name);
 }
 // ···
}

这段代码使用了一个保持实例数据私有的常见技术:

  • 每个 WeakMap 实现一个私有字段。

  • 它将每个实例与一个私有数据关联起来。

关于这个主题的更多信息:请参阅“JavaScript for impatient programmers”。

16.2.3?私有属性 vs. 私有字段
  • 私有属性的缺点:

    • 我们不能在子类中重用私有属性的名称(因为属性在运行时不是私有的)。

    • 在运行时没有封装。

  • 私有属性的优点:

    • 客户端可以规避封装并访问私有属性。如果有人需要解决 bug,这可能是有用的。换句话说:数据完全封装有利有弊。
16.2.4 受保护的属性

私有字段和私有属性不能在子类中访问(A 行):

class PrivatePerson {
 private name: string;
 constructor(name: string) {
 this.name = name;
 }
 sayHello() {
 return `Hello ${this.name}!`;
 }
}
class PrivateEmployee extends PrivatePerson {
 private company: string;
 constructor(name: string, company: string) {
 super(name);
 this.company = company;
 }
 sayHello() {
 // @ts-expect-error: Property 'name' is private and only
 // accessible within class 'PrivatePerson'. (2341)
 return `Hello ${this.name} from ${this.company}!`; // (A)
 } 
}

我们可以通过在 A 行将private改为protected来修复上一个示例(出于一致性的考虑,我们也在 B 行进行了切换):

class ProtectedPerson {
 protected name: string; // (A)
 constructor(name: string) {
 this.name = name;
 }
 sayHello() {
 return `Hello ${this.name}!`;
 }
}
class ProtectedEmployee extends ProtectedPerson {
 protected company: string; // (B)
 constructor(name: string, company: string) {
 super(name);
 this.company = company;
 }
 sayHello() {
 return `Hello ${this.name} from ${this.company}!`; // OK
 } 
}

16.3 私有构造函数

构造函数也可以是私有的。当我们有静态工厂方法并且希望客户端始终使用这些方法而不是直接使用构造函数时,这是很有用的。静态方法可以访问私有类成员,这就是为什么工厂方法仍然可以使用构造函数的原因。

在以下代码中,有一个静态工厂方法DataContainer.create()。它通过异步加载的数据设置实例。将异步代码放在工厂方法中使得实际类完全同步:

class DataContainer {
 #data: string;
 static async create() {
 const data = await Promise.resolve('downloaded'); // (A)
 return new this(data);
 }
 private constructor(data: string) {
 this.#data = data;
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
DataContainer.create()
 .then(dc => assert.equal(
 dc.getData(), 'DATA: downloaded'));

在实际代码中,我们会使用fetch()或类似的基于 Promise 的 API 来在 A 行异步加载数据。

私有构造函数防止DataContainer被子类化。如果我们想允许子类,我们必须将其设置为protected

16.4 初始化实例属性

16.4.1 严格的属性初始化

如果编译器设置--strictPropertyInitialization被打开(如果我们使用--strict,则是这种情况),那么 TypeScript 会检查所有声明的实例属性是否被正确初始化:

  • 要么通过构造函数中的赋值:

    class Point {
     x: number;
     y: number;
     constructor(x: number, y: number) {
     this.x = x;
     this.y = y;
     }
    }
    
  • 或通过属性声明的初始化程序:

    class Point {
     x = 0;
     y = 0;
    
     // No constructor needed
    }
    

然而,有时我们以 TypeScript 无法识别的方式初始化属性。然后我们可以使用感叹号(确定赋值断言)来关闭 TypeScript 的警告(A 行和 B 行):

class Point {
 x!: number; // (A)
 y!: number; // (B)
 constructor() {
 this.initProperties();
 }
 initProperties() {
 this.x = 0;
 this.y = 0;
 }
}
16.4.1.1 例子:通过对象设置实例属性

在下面的示例中,我们还需要确定赋值断言。在这里,我们通过构造函数参数props设置实例属性。

class CompilerError implements CompilerErrorProps { // (A)
 line!: number;
 description!: string;
 constructor(props: CompilerErrorProps) {
 Object.assign(this, props); // (B)
 }
}

// Helper interface for the parameter properties
interface CompilerErrorProps {
 line: number,
 description: string,
}

// Using the class:
const err = new CompilerError({
 line: 123,
 description: 'Unexpected token',
});

注:

  • 在 B 行,我们初始化了所有属性:我们使用Object.assign()将参数props的属性复制到this中。

  • 在 A 行,implements确保类声明了接口CompilerErrorProps中的所有属性。

16.4.2 使构造函数参数publicprivateprotected

如果我们对构造函数参数使用关键字public,那么 TypeScript 会为我们做两件事:

  • 它声明了一个具有相同名称的公共实例属性。

  • 它将参数分配给该实例属性。

因此,以下两个类是等价的:

class Point1 {
 constructor(public x: number, public y: number) {
 }
}

class Point2 {
 x: number;
 y: number;
 constructor(x: number, y: number) {
 this.x = x;
 this.y = y;
 }
}

如果我们使用privateprotected而不是public,那么相应的实例属性就是私有的或受保护的(而不是公共的)。

16.5 抽象类

在 TypeScript 中,两个构造可以是抽象的:

  • 抽象类不能被实例化。只有它的子类可以——如果它们自己不是抽象的。

  • 抽象方法没有实现,只有类型签名。每个具体的子类必须具有相同名称和兼容类型签名的具体方法。

    • 如果一个类有任何抽象方法,它也必须是抽象的。

以下代码演示了抽象类和方法。

一方面,有一个抽象的超类Printable及其辅助类StringBuilder

class StringBuilder {
 string = '';
 add(str: string) {
 this.string += str;
 }
}
abstract class Printable {
 toString() {
 const out = new StringBuilder();
 this.print(out);
 return out.string;
 }
 abstract print(out: StringBuilder): void;
}

另一方面,有具体的子类EntriesEntry

class Entries extends Printable {
 entries: Entry[];
 constructor(entries: Entry[]) {
 super();
 this.entries = entries;
 }
 print(out: StringBuilder): void {
 for (const entry of this.entries) {
 entry.print(out);
 }
 }
}
class Entry extends Printable {
 key: string;
 value: string;
 constructor(key: string, value: string) {
 super();
 this.key = key;
 this.value = value;
 }
 print(out: StringBuilder): void {
 out.add(this.key);
 out.add(': ');
 out.add(this.value);
 out.add('
');
 }
}

最后,这是我们使用EntriesEntry

const entries = new Entries([
 new Entry('accept-ranges', 'bytes'),
 new Entry('content-length', '6518'),
]);
assert.equal(
 entries.toString(),
 'accept-ranges: bytes
content-length: 6518
');

关于抽象类的注释:

  • 抽象类可以被视为具有一些成员已经有实现的接口。

  • 虽然一个类可以实现多个接口,但它最多只能扩展一个抽象类。

  • “抽象性”只存在于编译时。在运行时,抽象类是普通类,抽象方法不存在(因为它们只提供编译时信息)。

  • 抽象类可以被视为模板,其中每个抽象方法都是必须由子类填写(实现)的空白。

评论

十七、与类相关的类型

原文:exploringjs.com/tackling-ts/ch_class-related-types.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 17.1?类的两个原型链

  • 17.2?类的实例接口

  • 17.3?类的接口

    • 17.3.1?示例:从 JSON 转换和转换为 JSON

    • 17.3.2?示例:TypeScript 内置接口用于类Object及其实例

  • 17.4?类作为类型

    • 17.4.1?陷阱:类的结构工作,而不是名义上的
  • 17.5?进一步阅读


在这一章关于 TypeScript 的内容中,我们研究与类及其实例相关的类型。

17.1?类的两个原型链

考虑这个类:

class Counter extends Object {
 static createZero() {
 return new Counter(0);
 }
 value: number;
 constructor(value: number) {
 super();
 this.value = value;
 }
 increment() {
 this.value++;
 }
}
// Static method
const myCounter = Counter.createZero();
assert.ok(myCounter instanceof Counter);
assert.equal(myCounter.value, 0);

// Instance method
myCounter.increment();
assert.equal(myCounter.value, 1);

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2:类Counter创建的对象。左侧:类及其超类Object。右侧:实例myCounterCounter的原型属性和超类Object的原型方法。

图 2 中的图表显示了类Counter的运行时结构。在这个图表中有两个对象的原型链:

  • 类(左侧):静态原型链由组成类Counter的对象组成。类Counter的原型对象是它的超类Object

  • 实例(右侧):实例原型链由组成实例myCounter的对象组成。链以实例myCounter开始,然后是Counter.prototype(其中包含类Counter的原型方法)和Object.prototype(其中包含类Object的原型方法)。

在本章中,我们首先探讨实例对象,然后是作为对象的类。

17.2?类的实例接口

接口指定对象提供的服务。例如:

interface CountingService {
 value: number;
 increment(): void;
}

TypeScript 的接口是结构化的:为了使一个对象实现一个接口,它只需要具有正确类型的正确属性。我们可以在下面的例子中看到这一点:

const myCounter2: CountingService = new Counter(3);

结构接口很方便,因为我们甚至可以为已经存在的对象创建接口(即,在事后引入它们)。

如果我们提前知道一个对象必须实现一个给定的接口,通常最好提前检查它是否实现了,以避免后来的意外。我们可以通过implements来对类的实例进行这样的检查:

class Counter implements CountingService {
 // ···
};

注:

  • TypeScript 不区分继承的属性(如.increment)和自有属性(如.value)。

  • 另外,接口忽略私有属性,并且不能通过接口指定私有属性。这是可以预料的,因为私有数据仅供内部使用。

17.3?类的接口

类本身也是对象(函数)。因此,我们可以使用接口来指定它们的属性。这里的主要用例是描述对象的工厂。下一节给出了一个例子。

17.3.1?示例:从 JSON 转换和转换为 JSON

以下两个接口可用于支持其实例从 JSON 转换和转换为 JSON 的类:

// Converting JSON to instances
interface JsonStatic {
 fromJson(json: any): JsonInstance;
}

// Converting instances to JSON
interface JsonInstance {
 toJson(): any;
}

我们在下面的代码中使用这些接口:

class Person implements JsonInstance {
 static fromJson(json: any): Person {
 if (typeof json !== 'string') {
 throw new TypeError(json);
 }
 return new Person(json);
 }
 name: string;
 constructor(name: string) {
 this.name = name;
 }
 toJson(): any {
 return this.name;
 }
}

这是我们可以立即检查类Person(作为对象)是否实现了接口JsonStatic的方法:

// Assign the class to a type-annotated variable
const personImplementsJsonStatic: JsonStatic = Person;

以下方式进行此检查可能看起来是一个好主意:

const Person: JsonStatic = class implements JsonInstance {
 // ···
};

然而,这并不真正起作用:

  • 我们不能new-call Person,因为JsonStatic没有构造签名。

  • 如果Person具有超出.fromJson()的静态属性,TypeScript 不会让我们访问它们。

17.3.2 示例:TypeScript 的内置接口用于类Object及其实例

看一下 TypeScript 内置类型是很有启发性的:

一方面,接口ObjectConstructor是为了类Object本身:

/**
 * Provides functionality common to all JavaScript objects.
 */
declare var Object: ObjectConstructor;

interface ObjectConstructor {
 new(value?: any): Object;
 (): any;
 (value: any): any;

 /** A reference to the prototype for a class of objects. */
 readonly prototype: Object;

 /**
 * Returns the prototype of an object.
 * @param  o The object that references the prototype.
 */
 getPrototypeOf(o: any): any;

}

另一方面,接口Object是为了Object的实例:

interface Object {
 /** The initial value of Object.prototype.constructor is the standard built-in Object constructor. */
 constructor: Function;

 /** Returns a string representation of an object. */
 toString(): string;
}

名称Object在两个不同的语言级别上都被使用了:

  • 在动态级别,对于一个全局变量。

  • 在静态级别,对于一个类型。

17.4 类作为类型

考虑以下类:

class Color {
 name: string;
 constructor(name: string) {
 this.name = name;
 }
}

这个类定义创建了两个东西。

首先,一个名为Color的构造函数(可以通过new调用):

assert.equal(
 typeof Color, 'function')

其次,一个名为Color的接口,匹配Color的实例:

const green: Color = new Color('green');

这里有证据表明Color确实是一个接口:

interface RgbColor extends Color {
 rgbValue: [number, number, number];
}
17.4.1 陷阱:类在结构上工作,而不是名义上。

不过有一个陷阱:使用Color作为静态类型并不是一个非常严格的检查:

class Color {
 name: string;
 constructor(name: string) {
 this.name = name;
 }
}
class Person {
 name: string;
 constructor(name: string) {
 this.name = name;
 }
}

const person: Person = new Person('Jane');
const color: Color = person; // (A)

为什么 TypeScript 在 A 行没有抱怨呢?这是由于结构类型:PersonColor的实例具有相同的结构,因此在静态上是兼容的。

17.4.1.1 关闭结构类型

我们可以通过添加私有属性使这两组对象不兼容:

class Color {
 name: string;
 private branded = true;
 constructor(name: string) {
 this.name = name;
 }
}
class Person {
 name: string;
 private branded = true;
 constructor(name: string) {
 this.name = name;
 }
}

const person: Person = new Person('Jane');

// @ts-expect-error: Type 'Person' is not assignable to type 'Color'.
//   Types have separate declarations of a private property
//   'branded'. (2322)
const color: Color = person;

这种情况下,私有属性关闭了结构类型。

17.5 进一步阅读

  • 章节“原型链和类” 在“JavaScript for impatient programmers”

评论

十八、类作为值的类型

原文:exploringjs.com/tackling-ts/ch_classes-as-values.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 18.1?特定类的类型

  • 18.2?类型操作符typeof

    • 18.2.1?构造函数类型文本

    • 18.2.2?带有构造签名的对象类型文本

  • 18.3?类的通用类型:Class<T>

    • 18.3.1?示例:创建实例

    • 18.3.2?示例:带有运行时检查的类型转换

    • 18.3.3?示例:在运行时类型安全的映射

    • 18.3.4?陷阱:Class<T>不匹配抽象类


在本章中,我们探讨了类作为值:

  • 我们应该为这些值使用什么类型?

  • 这些类型的用例是什么?

18.1?特定类的类型

考虑以下类:

class Point {
 x: number;
 y: number;
 constructor(x: number, y: number) {
 this.x = x;
 this.y = y;
 }
}

这个函数接受一个类并创建它的一个实例:

function createPoint(PointClass: ???, x: number, y: number) {
 return new PointClass(x, y);
}

如果我们希望参数PointClass的类型为Point或其子类,应该使用什么类型?

18.2?类型操作符typeof

在§7.7“两种语言级别:动态 vs. 静态”中,我们探讨了 TypeScript 的两种语言级别:

  • 动态级别:JavaScript(代码和值)

  • 静态级别:TypeScript(静态类型)

Point创建了两个东西:

  • 构造函数Point

  • 接口Point用于Point的实例

根据我们提到Point的地方不同,它表示不同的东西。这就是为什么我们不能将类型Point用于PointClass:它匹配类Point实例,而不是类Point本身。

相反,我们需要使用类型操作符typeof(TypeScript 语法的一部分,也存在于 JavaScript 中)。typeof v代表动态(!)值v的类型。

function createPoint(PointClass: typeof Point, x: number, y: number) { // (A)
 return new PointClass(x, y);
}

// %inferred-type: Point
const point = createPoint(Point, 3, 6);
assert.ok(point instanceof Point);
18.2.1?构造函数类型文本

构造函数类型文本是一个带有前缀new的函数类型文本(A 行)。前缀表示PointClass是一个必须通过new调用的函数。

function createPoint(
 PointClass: new (x: number, y: number) => Point, // (A)
 x: number, y: number
) {
 return new PointClass(x, y);
}
18.2.2?带有构造签名的对象类型文本

回想一下接口和对象文本类型(OLT)的成员包括方法签名和调用签名。调用签名使接口和 OLT 能够描述函数。

同样,构造签名使接口和 OLT 能够描述构造函数。它们看起来像带有前缀new的调用签名。在下一个示例中,PointClass具有带有构造签名的对象文本类型:

function createPoint(
 PointClass: {new (x: number, y: number): Point},
 x: number, y: number
) {
 return new PointClass(x, y);
}

18.3?类的通用类型:Class<T>

根据我们所学的知识,我们现在可以创建一个类的通用类型作为值 - 通过引入类型参数T

type Class<T> = new (...args: any[]) => T;

除了类型别名,我们还可以使用接口:

interface Class<T> {
 new(...args: any[]): T;
}

Class<T>是一个类的类型,其实例匹配类型T

18.3.1?示例:创建实例

Class<T>使我们能够编写createPoint()的通用版本:

function createInstance<T>(AnyClass: Class<T>, ...args: any[]): T {
 return new AnyClass(...args);
}

createInstance()的使用方法如下:

class Person {
 constructor(public name: string) {}
}

// %inferred-type: Person
const jane = createInstance(Person, 'Jane');

createInstance()new操作符,通过一个函数实现。

18.3.2?示例:带有运行时检查的类型转换

我们可以使用Class<T>来实现类型转换:

function cast<T>(AnyClass: Class<T>, obj: any): T {
 if (! (obj instanceof AnyClass)) {
 throw new Error(`Not an instance of ${AnyClass.name}: ${obj}`)
 }
 return obj;
}

通过cast(),我们可以将值的类型更改为更具体的类型。这在运行时也是安全的,因为我们既静态地更改了类型,又执行了动态检查。以下代码提供了一个示例:

function parseObject(jsonObjectStr: string): Object {
 // %inferred-type: any
 const parsed = JSON.parse(jsonObjectStr);
 return cast(Object, parsed);
}
18.3.3?示例:在运行时类型安全的映射

Class<T>cast()的一个用例是类型安全的映射:

class TypeSafeMap {
 #data = new Map<any, any>();
 get<T>(key: Class<T>) {
 const value = this.#data.get(key);
 return cast(key, value);
 }
 set<T>(key: Class<T>, value: T): this {
 cast(key, value); // runtime check
 this.#data.set(key, value);
 return this;
 }
 has(key: any) {
 return this.#data.has(key);
 }
}

TypeSafeMap中每个条目的键都是一个类。该类确定条目值的静态类型,并且在运行时用于检查。

这是TypeSafeMap的实际应用:

const map = new TypeSafeMap();

map.set(RegExp, /abc/);

// %inferred-type: RegExp
const re = map.get(RegExp);

// Static and dynamic error!
assert.throws(
 // @ts-expect-error: Argument of type '"abc"' is not assignable
 // to parameter of type 'Date'.
 () => map.set(Date, 'abc'));
18.3.4?陷阱:Class<T>与抽象类不匹配

当期望Class<T>时,我们不能使用抽象类:

abstract class Shape {
}
class Circle extends Shape {
 // ···
}

// @ts-expect-error: Type 'typeof Shape' is not assignable to type
// 'Class<Shape>'.
//   Cannot assign an abstract constructor type to a non-abstract
//   constructor type. (2322)
const shapeClasses1: Array<Class<Shape>> = [Circle, Shape];

为什么呢?原因是构造函数类型文字和构造签名应该只用于实际可以被new调用的值(GitHub 上有更多信息的问题)。

这是一个变通方法:

type Class2<T> = Function & {prototype: T};

const shapeClasses2: Array<Class2<Shape>> = [Circle, Shape];

这种方法的缺点:

  • 稍微令人困惑。

  • 具有此类型的值不能用于instanceof检查(作为右操作数)。

评论

十九、数组的类型化

原文:exploringjs.com/tackling-ts/ch_typing-arrays.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 19.1?数组的角色

  • 19.2?数组的类型化方式

    • 19.2.1?数组角色“列表”:数组类型字面量 vs. 接口类型Array

    • 19.2.2?数组角色“元组”:元组类型字面量

    • 19.2.3?也是类似数组的对象:带有索引签名的接口

  • 19.3?陷阱:类型推断并不总是正确获取数组类型

    • 19.3.1?推断数组类型很困难

    • 19.3.2?非空数组字面量的类型推断

    • 19.3.3?空数组字面量的类型推断

    • 19.3.4?对数组和类型推断进行const断言

  • 19.4?陷阱:TypeScript 假设索引永远不会越界


在本章中,我们将讨论如何在 TypeScript 中为数组添加类型。

19.1?数组的角色

数组在 JavaScript 中可以扮演以下角色(单一或混合):

  • 列表:所有元素具有相同的类型。数组的长度不同。

  • 元组:数组的长度是固定的。元素不一定具有相同的类型。

TypeScript 通过提供各种数组类型化的方式来适应这两种角色。我们将在下面看看这些方式。

19.2?数组的类型化方式

19.2.1?数组角色“列表”:数组类型字面量 vs. 接口类型Array

数组类型字面量由元素类型后跟[]组成。在下面的代码中,数组类型字面量是string[]

// Each Array element has the type `string`:
const myStringArray: string[] = ['fee', 'fi', 'fo', 'fum'];

数组类型字面量是使用全局通用接口类型Array的简写:

const myStringArray: Array<string> = ['fee', 'fi', 'fo', 'fum'];

如果元素类型更复杂,我们需要使用数组类型字面量的括号:

(number|string)[]
(() => boolean)[]

在这种情况下,通用类型Array更适用:

Array<number|string>
Array<() => boolean>
19.2.2?数组角色“元组”:元组类型字面量

如果数组的长度固定,并且每个元素具有不同的固定类型,取决于其位置,则我们可以使用元组类型字面量,例如[string, string, boolean]

const yes: [string, string, boolean] = ['oui', 'sí', true];
19.2.3?也是类似数组的对象:带有索引签名的接口

如果一个接口只有一个索引签名,我们可以用它来表示数组:

interface StringArray {
 [index: number]: string;
}
const strArr: StringArray = ['Huey', 'Dewey', 'Louie'];

具有索引签名和属性签名的接口仅适用于对象(因为索引元素和属性需要同时定义):

interface FirstNamesAndLastName {
 [index: number]: string;
 lastName: string;
}

const ducks: FirstNamesAndLastName = {
 0: 'Huey',
 1: 'Dewey',
 2: 'Louie',
 lastName: 'Duck',
};

19.3?陷阱:类型推断并不总是正确获取数组类型

19.3.1?推断数组类型很困难

由于数组的两种角色,TypeScript 不可能总是猜对类型。例如,考虑以下分配给变量fields的数组字面量:

const fields: Fields = [
 ['first', 'string', true],
 ['last', 'string', true],
 ['age', 'number', false],
];

fields的最佳类型是什么?以下都是合理的选择:

type Fields = Array<[string, string, boolean]>;
type Fields = Array<[string, ('string'|'number'), boolean]>;
type Fields = Array<Array<string|boolean>>;
type Fields = [
 [string, string, boolean],
 [string, string, boolean],
 [string, string, boolean],
];
type Fields = [
 [string, 'string', boolean],
 [string, 'string', boolean],
 [string, 'number', boolean],
];
type Fields = [
 Array<string|boolean>,
 Array<string|boolean>,
 Array<string|boolean>,
];
19.3.2?非空数组字面量的类型推断

当我们使用非空数组字面量时,TypeScript 的默认值是推断列表类型(而不是元组类型):

// %inferred-type: (string | number)[]
const arr = [123, 'abc'];

然而,这并不总是我们想要的:

function func(p: [number, number]) {
 return p;
}
// %inferred-type: number[]
const pair1 = [1, 2];

// @ts-expect-error: Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'. [...]
func(pair1);

我们可以通过在const声明中添加类型注释来解决这个问题,从而避免类型推断:

const pair2: [number, number] = [1, 2];
func(pair2); // OK
19.3.3?空数组字面量的类型推断

如果我们用空数组字面量初始化一个变量,那么 TypeScript 最初会推断类型为any[],并在我们进行更改时逐渐更新该类型:

// %inferred-type: any[]
const arr1 = [];

arr1.push(123);
// %inferred-type: number[]
arr1;

arr1.push('abc');
// %inferred-type: (string | number)[]
arr1;

请注意,初始推断类型不受后续发生的影响。

如果我们使用赋值而不是.push(),事情会保持不变:

// %inferred-type: any[]
const arr1 = [];

arr1[0] = 123;
// %inferred-type: number[]
arr1;

arr1[1] = 'abc';
// %inferred-type: (string | number)[]
arr1;

相反,如果数组文字至少有一个元素,则元素类型是固定的,以后不会改变:

// %inferred-type: number[]
const arr = [123];

// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'number'. (2345)
arr.push('abc');
19.3.4?数组和类型推断的const断言

我们可以在数组文字后缀中使用a const assertion:

// %inferred-type: readonly ["igneous", "metamorphic", "sedimentary"]
const rockCategories =
 ['igneous', 'metamorphic', 'sedimentary'] as const;

我们声明rockCategories不会改变。这有以下影响:

  • 数组变为readonly - 我们不能使用改变它的操作:

    // @ts-expect-error: Property 'push' does not exist on type
    // 'readonly ["igneous", "metamorphic", "sedimentary"]'. (2339)
    rockCategories.push('sand');
    
  • TypeScript 推断出一个元组。比较:

    // %inferred-type: string[]
    const rockCategories2 = ['igneous', 'metamorphic', 'sedimentary'];
    
  • TypeScript 推断出文字类型(例如"igneous"等)而不是更一般的类型。也就是说,推断的元组类型不是[string, string, string]

以下是使用和不使用const断言的更多数组文字的示例:

// %inferred-type: readonly [1, 2, 3, 4]
const numbers1 = [1, 2, 3, 4] as const;
// %inferred-type: number[]
const numbers2 = [1, 2, 3, 4];

// %inferred-type: readonly [true, "abc"]
const booleanAndString1 = [true, 'abc'] as const;
// %inferred-type: (string | boolean)[]
const booleanAndString2 = [true, 'abc'];
19.3.4.1?const断言的潜在陷阱

const断言有两个潜在的陷阱。

首先,推断类型尽可能狭窄。这对于使用let声明的变量会造成问题:我们不能分配除了初始化时使用的元组之外的任何其他元组:

let arr = [1, 2] as const;

arr = [1, 2]; // OK

// @ts-expect-error: Type '3' is not assignable to type '2'. (2322)
arr = [1, 3];

其次,通过as const声明的元组不能被改变:

let arr = [1, 2] as const;

// @ts-expect-error: Cannot assign to '1' because it is a read-only
// property. (2540)
arr[1] = 3;

这既不是优势也不是劣势,但我们需要意识到这一点。

19.4?陷阱:TypeScript 假设索引永远不会超出范围

每当我们通过索引访问数组元素时,TypeScript 总是假设索引在范围内(A 行):

const messages: string[] = ['Hello'];

// %inferred-type: string
const message = messages[3]; // (A)

由于这个假设,message的类型是string。而不是undefinedundefined|string,正如我们可能期望的那样。

如果我们使用元组类型,我们会得到一个错误:

const messages: [string] = ['Hello'];

// @ts-expect-error: Tuple type '[string]' of length '1' has no element
// at index '1'. (2493)
const message = messages[1];

as const会产生相同的效果,因为它会导致推断出一个元组类型。

评论

二十、函数类型

原文:exploringjs.com/tackling-ts/ch_typing-functions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 20.1?定义静态类型函数

    • 20.1.1?函数声明

    • 20.1.2?箭头函数

  • 20.2?函数类型

    • 20.2.1?函数类型签名

    • 20.2.2?带有调用签名的接口

    • 20.2.3?检查可调用值是否匹配函数类型

  • 20.3?参数

    • 20.3.1?何时必须对参数进行类型注释?

    • 20.3.2?可选参数

    • 20.3.3?剩余参数

    • 20.3.4?命名参数

    • 20.3.5?this作为参数(高级)

  • 20.4?重载(高级)

    • 20.4.1?重载函数声明

    • 20.4.2?通过接口进行重载

    • 20.4.3?基于字符串参数的重载(事件处理等)

    • 20.4.4?重载方法

  • 20.5?可赋值性(高级)

    • 20.5.1?可赋值性规则

    • 20.5.2?函数赋值规则的后果

  • 20.6?进一步阅读和本章的来源


本章探讨了 TypeScript 中函数的静态类型。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 在本章中,“函数”指的是“函数或方法或构造函数”

在本章中,关于函数的大部分内容(特别是参数处理方面)也适用于方法和构造函数。

20.1?定义静态类型函数

20.1.1?函数声明

这是 TypeScript 中函数声明的一个例子:

function repeat1(str: string, times: number): string { // (A)
 return str.repeat(times);
}
assert.equal(
 repeat1('*', 5), '*****');
  • 参数:如果编译器选项--noImplicitAny打开(如果--strict打开),则每个参数的类型必须是可推断的或明确指定的。(我们稍后会更仔细地看一下推断。)在这种情况下,无法进行推断,这就是为什么strtimes有类型注释的原因。

  • 返回值:默认情况下,函数的返回类型是推断的。通常这已经足够好了。在这种情况下,我们选择明确指定repeat1()的返回类型为string(A 行中的最后一个类型注释)。

20.1.2?箭头函数

repeat1()的箭头函数版本如下所示:

const repeat2 = (str: string, times: number): string => {
 return str.repeat(times);
};

在这种情况下,我们也可以使用表达式体:

const repeat3 = (str: string, times: number): string =>
 str.repeat(times);

20.2?函数类型

20.2.1?函数类型签名

我们可以通过函数类型签名为函数定义类型:

type Repeat = (str: string, times: number) => string;

这种类型的函数名为Repeat。它匹配所有具有以下特征的函数:

  • 两个类型分别为stringnumber的参数。我们需要在函数类型签名中命名参数,但在检查两个函数类型是否兼容时,名称会被忽略。

  • 返回类型为string。请注意,这次类型是由箭头分隔的,不能省略。

这种类型匹配更多的函数。我们将在本章后面探讨可赋值性的规则时学习到更多信息。

20.2.2?具有调用签名的接口

我们还可以使用接口来定义函数类型:

interface Repeat {
 (str: string, times: number): string; // (A)
}

注意:

  • A 行中的接口成员是调用签名。它看起来类似于方法签名,但没有名称。

  • 结果的类型由冒号(而不是箭头)分隔,并且不能被省略。

一方面,接口更冗长。另一方面,它们让我们指定函数的属性(这很少见,但确实会发生):

interface Incrementor1 {
 (x: number): number;
 increment: number;
}

我们还可以通过函数签名类型和对象字面类型的交集类型(&)来指定属性:

type Incrementor2 =
 (x: number) => number
 & { increment: number }
;
20.2.3?检查可调用值是否与函数类型匹配

例如,考虑以下情景:一个库导出以下函数类型。

type StringPredicate = (str: string) => boolean;

我们想要定义一个类型与StringPredicate兼容的函数。并且我们希望立即检查是否确实如此(而不是在第一次使用时才发现)。

20.2.3.1?检查箭头函数

如果我们通过const声明一个变量,我们可以通过类型注释进行检查:

const pred1: StringPredicate = (str) => str.length > 0;

注意,我们不需要指定参数str的类型,因为 TypeScript 可以使用StringPredicate来推断它。

20.2.3.2?检查函数声明(简单)

检查函数声明更加复杂:

function pred2(str: string): boolean {
 return str.length > 0;
}

// Assign the function to a type-annotated variable
const pred2ImplementsStringPredicate: StringPredicate = pred2;
20.2.3.3?检查函数声明(奢侈的)

以下解决方案有点过头(即,如果你不完全理解也不要担心),但它演示了几个高级特性:

function pred3(...[str]: Parameters<StringPredicate>)
 : ReturnType<StringPredicate> {
 return str.length > 0;
 }
  • 参数:我们使用Parameters<>来提取具有参数类型的元组。三个点声明了一个剩余参数,它收集元组/数组中的所有参数。[str]对该元组进行解构。(本章后面将更多介绍剩余参数。)

  • 返回值:我们使用ReturnType<>来提取返回类型。

20.3?参数

20.3.1?何时必须对参数进行类型注释?

回顾:如果打开了--noImplicitAny--strict会打开它),则每个参数的类型必须是可推断的或明确指定的。

在以下示例中,TypeScript 无法推断str的类型,我们必须指定它:

function twice(str: string) {
 return str + str;
}

在 A 行,TypeScript 可以使用类型StringMapFunction来推断str的类型,我们不需要添加类型注释:

type StringMapFunction = (str: string) => string;
const twice: StringMapFunction = (str) => str + str; // (A)

在这里,TypeScript 可以使用.map()的类型来推断str的类型:

assert.deepEqual(
 ['a', 'b', 'c'].map((str) => str + str),
 ['aa', 'bb', 'cc']);

这是.map()的类型:

interface Array<T> {
 map<U>(
 callbackfn: (value: T, index: number, array: T[]) => U,
 thisArg?: any
 ): U[];
 // ···
}
20.3.2?可选参数

在本节中,我们将看几种允许参数被省略的方法。

20.3.2.1?可选参数:str?: string

如果在参数名称后面加上问号,该参数就变成了可选的,在调用函数时可以省略:

function trim1(str?: string): string {
 // Internal type of str:
 // %inferred-type: string | undefined
 str;

 if (str === undefined) {
 return '';
 }
 return str.trim();
}

// External type of trim1:
// %inferred-type: (str?: string | undefined) => string
trim1;

这是trim1()的调用方式:

assert.equal(
 trim1('
 abc 	'), 'abc');

assert.equal(
 trim1(), '');

// `undefined` is equivalent to omitting the parameter
assert.equal(
 trim1(undefined), '');
20.3.2.2?联合类型:str: undefined|string

在外部,trim1()的参数str的类型是string|undefined。因此,trim1()在大多数情况下等同于以下函数。

function trim2(str: undefined|string): string {
 // Internal type of str:
 // %inferred-type: string | undefined
 str;

 if (str === undefined) {
 return '';
 }
 return str.trim();
}

// External type of trim2:
// %inferred-type: (str: string | undefined) => string
trim2;

trim2()trim1()唯一不同的地方是在函数调用时不能省略参数(A 行)。换句话说:当省略类型为undefined|T的参数时,我们必须明确指定。

assert.equal(
 trim2('
 abc 	'), 'abc');

// @ts-expect-error: Expected 1 arguments, but got 0. (2554)
trim2(); // (A)

assert.equal(
 trim2(undefined), ''); // OK!
20.3.2.3?参数默认值:str = ''

如果我们为str指定了参数默认值,我们不需要提供类型注释,因为 TypeScript 可以推断类型:

function trim3(str = ''): string {
 // Internal type of str:
 // %inferred-type: string
 str;

 return str.trim();
}

// External type of trim2:
// %inferred-type: (str?: string) => string
trim3;

注意,str的内部类型是string,因为默认值确保它永远不会是undefined

让我们调用trim3()

assert.equal(
 trim3('
 abc 	'), 'abc');

// Omitting the parameter triggers the parameter default value:
assert.equal(
 trim3(), '');

// `undefined` is allowed and triggers the parameter default value:
assert.equal(
 trim3(undefined), '');
20.3.2.4?参数默认值加类型注释

我们还可以同时指定类型和默认值:

function trim4(str: string = ''): string {
 return str.trim();
}
20.3.3?剩余参数
20.3.3.1?具有数组类型的剩余参数

剩余参数将所有剩余参数收集到一个数组中。因此,它的静态类型通常是数组。在下面的例子中,parts是一个剩余参数:

function join(separator: string, ...parts: string[]) {
 return parts.join(separator);
}
assert.equal(
 join('-', 'state', 'of', 'the', 'art'),
 'state-of-the-art');
20.3.3.2?具有元组类型的剩余参数

下一个示例演示了两个特性:

  • 我们可以使用元组类型,如[string, number],来作为剩余参数。

  • 我们可以解构剩余参数(不仅仅是普通参数)。

function repeat1(...[str, times]: [string, number]): string {
 return str.repeat(times);
}

repeat1()等同于以下函数:

function repeat2(str: string, times: number): string {
 return str.repeat(times);
}
20.3.4?命名参数

命名参数是 JavaScript 中的一种流行模式,其中使用对象文字为每个参数指定名称。看起来如下:

assert.equal(
 padStart({str: '7', len: 3, fillStr: '0'}),
 '007');

在纯 JavaScript 中,函数可以使用解构来访问命名参数值。遗憾的是,在 TypeScript 中,我们还必须为对象文字指定类型,这导致了冗余:

function padStart({ str, len, fillStr = ' ' } // (A)
 : { str: string, len: number, fillStr: string }) { // (B)
 return str.padStart(len, fillStr);
}

请注意,解构(包括fillStr的默认值)都发生在 A 行,而 B 行完全是关于 TypeScript 的。

可以定义一个单独的类型,而不是我们在 B 行中使用的内联对象文字类型。但是,在大多数情况下,我更喜欢不这样做,因为它略微违反了参数的本质,参数是每个函数的本地和唯一的。如果您更喜欢在函数头中有更少的内容,那也可以。

20.3.5?this作为参数(高级)

每个普通函数始终具有隐式参数this-这使其可以在对象中用作方法。有时我们需要为this指定类型。对于这种用例,TypeScript 有专门的语法:普通函数的参数之一可以命名为this。这样的参数仅在编译时存在,并在运行时消失。

例如,考虑以下用于 DOM 事件源的接口(稍微简化版本):

interface EventSource {
 addEventListener(
 type: string,
 listener: (this: EventSource, ev: Event) => any,
 options?: boolean | AddEventListenerOptions
 ): void;
 // ···
}

回调listenerthis始终是EventSource的实例。

下一个示例演示了 TypeScript 如何使用this参数提供的类型信息来检查.call()的第一个参数(A 行和 B 行):

function toIsoString(this: Date): string {
 return this.toISOString();
}

// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'Date'. (2345)
assert.throws(() => toIsoString.call('abc')); // (A) error

toIsoString.call(new Date()); // (B) OK

此外,我们不能将toIsoString()作为对象obj的方法调用,因为它的接收器不是Date的实例:

const obj = { toIsoString };
// @ts-expect-error: The 'this' context of type
// '{ toIsoString: (this: Date) => string; }' is not assignable to
// method's 'this' of type 'Date'. [...]
assert.throws(() => obj.toIsoString()); // error
obj.toIsoString.call(new Date()); // OK

20.4?重载(高级)

有时单个类型签名无法充分描述函数的工作原理。

20.4.1?重载函数声明

考虑我们在以下示例中调用的getFullName()函数(A 行和 B 行):

interface Customer {
 id: string;
 fullName: string;
}
const jane = {id: '1234', fullName: 'Jane Bond'};
const lars = {id: '5678', fullName: 'Lars Croft'};
const idToCustomer = new Map<string, Customer>([
 ['1234', jane],
 ['5678', lars],
]);

assert.equal(
 getFullName(idToCustomer, '1234'), 'Jane Bond'); // (A)

assert.equal(
 getFullName(lars), 'Lars Croft'); // (B)

我们如何实现getFullName()?以下实现适用于前面示例中的两个函数调用:

function getFullName(
 customerOrMap: Customer | Map<string, Customer>,
 id?: string
): string {
 if (customerOrMap instanceof Map) {
 if (id === undefined) throw new Error();
 const customer = customerOrMap.get(id);
 if (customer === undefined) {
 throw new Error('Unknown ID: ' + id);
 }
 customerOrMap = customer;
 } else {
 if (id !== undefined) throw new Error();
 }
 return customerOrMap.fullName;
}

但是,使用这种类型签名,编译时可以产生运行时错误的函数调用是合法的:

assert.throws(() => getFullName(idToCustomer)); // missing ID
assert.throws(() => getFullName(lars, '5678')); // ID not allowed

以下代码修复了这些问题:

function getFullName(customerOrMap: Customer): string; // (A)
function getFullName( // (B)
 customerOrMap: Map<string, Customer>, id: string): string;
function getFullName( // (C)
 customerOrMap: Customer | Map<string, Customer>,
 id?: string
): string {
 // ···
}

// @ts-expect-error: Argument of type 'Map<string, Customer>' is not
// assignable to parameter of type 'Customer'. [...]
getFullName(idToCustomer); // missing ID

// @ts-expect-error: Argument of type '{ id: string; fullName: string; }'
// is not assignable to parameter of type 'Map<string, Customer>'.
// [...]
getFullName(lars, '5678'); // ID not allowed

这里发生了什么?getFullName()的类型签名被重载:

  • 实际实现从 C 行开始。与前面的示例相同。

  • 在 A 行和 B 行中,有两个类型签名(没有主体的函数头),可以用于getFullName()。实际实现的类型签名不能使用!

我的建议是只有在无法避免时才使用重载。一种替代方法是将重载的函数拆分为具有不同名称的多个函数-例如:

  • getFullName()

  • getFullNameViaMap()

20.4.2?通过接口进行重载

在接口中,我们可以有多个不同的调用签名。这使我们能够在以下示例中使用接口GetFullName进行重载:

interface GetFullName {
 (customerOrMap: Customer): string;
 (customerOrMap: Map<string, Customer>, id: string): string;
}

const getFullName: GetFullName = (
 customerOrMap: Customer | Map<string, Customer>,
 id?: string
): string => {
 if (customerOrMap instanceof Map) {
 if (id === undefined) throw new Error();
 const customer = customerOrMap.get(id);
 if (customer === undefined) {
 throw new Error('Unknown ID: ' + id);
 }
 customerOrMap = customer;
 } else {
 if (id !== undefined) throw new Error();
 }
 return customerOrMap.fullName;
}
20.4.3?基于字符串参数的重载(事件处理等)

在下一个示例中,我们通过接口进行重载并使用字符串文字类型(例如'click')。这使我们能够根据参数type的值更改参数listener的类型:

function addEventListener(elem: HTMLElement, type: 'click',
 listener: (event: MouseEvent) => void): void;
function addEventListener(elem: HTMLElement, type: 'keypress',
 listener: (event: KeyboardEvent) => void): void;
function addEventListener(elem: HTMLElement, type: string,  // (A)
 listener: (event: any) => void): void {
 elem.addEventListener(type, listener); // (B)
 }

在这种情况下,相对难以正确获取实现的类型(从 A 行开始)以使主体中的语句(B 行)起作用。作为最后的手段,我们总是可以使用类型any

20.4.4?重载方法
20.4.4.1?重载具体方法

下一个示例演示了方法的重载:方法.add()被重载。

class StringBuilder {
 #data = '';

 add(num: number): this;
 add(bool: boolean): this;
 add(str: string): this;
 add(value: any): this {
 this.#data += String(value);
 return this;
 }

 toString() {
 return this.#data;
 }
}

const sb = new StringBuilder();
sb
 .add('I can see ')
 .add(3)
 .add(' monkeys!')
;
assert.equal(
 sb.toString(), 'I can see 3 monkeys!')
20.4.4.2?重载接口方法

Array.from()的类型定义是重载接口方法的一个示例:

interface ArrayConstructor {
 from<T>(arrayLike: ArrayLike<T>): T[];
 from<T, U>(
 arrayLike: ArrayLike<T>,
 mapfn: (v: T, k: number) => U,
 thisArg?: any
 ): U[];
}
  • 在第一个签名中,返回的数组具有与参数相同的元素类型。

  • 在第二个签名中,返回的数组元素与mapfn的结果具有相同的类型。这个版本的Array.from()类似于Array.prototype.map()

20.5?可分配性(高级)

在本节中,我们将研究可分配性的类型兼容性规则:类型为Src的函数是否可以转移到类型为Trg的存储位置(变量、对象属性、参数等)?

理解可分配性有助于我们回答诸如:

  • 在函数调用中,对于形式参数的函数类型签名,哪些函数可以作为实际参数传递?

  • 对于属性的函数类型签名,可以分配给它的函数是哪些?

20.5.1?可分配性的规则

在本小节中,我们将研究可分配性的一般规则(包括函数的规则)。在下一小节中,我们将探讨这些规则对函数的含义。

如果以下条件之一成立,则类型Src可以分配给类型Trg

  • SrcTrg是相同的类型。

  • SrcTrgany类型。

  • Src是一个字符串字面类型,Trg是原始类型 String。

  • Src是一个联合类型,Src的每个组成类型都可以分配给Trg

  • SrcTrg是函数类型,并且:

    • Trg具有剩余参数,或者Src的必需参数数量小于或等于Trg的总参数数量。

    • 对于两个签名中都存在的参数,Trg中的每个参数类型都可以分配给Src中对应的参数类型。

    • Trg的返回类型是void,或者Src的返回类型可以分配给Trg的返回类型。

  • (其余条件被省略。)

20.5.2?函数分配规则的后果

在本小节中,我们将研究分配规则对以下两个函数targetFuncsourceFunc的含义:

const targetFunc: Trg = sourceFunc;
20.5.2.1?参数和结果的类型
  • 目标参数类型必须可以分配给相应的源参数类型。

    • 为什么?目标接受的任何内容也必须被源接受。
  • 源返回类型必须可以分配给目标返回类型。

    • 为什么?源返回的任何内容都必须与目标设置的期望兼容。

示例:

const trg1: (x: RegExp) => Object = (x: Object) => /abc/;

以下示例演示了如果目标返回类型是void,那么源返回类型就不重要。为什么?在 TypeScript 中,void结果总是被忽略的。

const trg2: () => void = () => new Date();
20.5.2.2?参数的数量

源不得比目标具有更多的参数:

// @ts-expect-error: Type '(x: string) => string' is not assignable to
// type '() => string'. (2322)
const trg3: () => string = (x: string) => 'abc';

源可以比目标具有更少的参数:

const trg4: (x: string) => string = () => 'abc';

为什么?目标指定了对源的期望:它必须接受参数x。它确实接受了(但它忽略了它)。这种宽松性使得:

['a', 'b'].map(x => x + x)

.map()的回调只有三个参数中的一个:

map<U>(
 callback: (value: T, index: number, array: T[]) => U,
 thisArg?: any
): U[];

20.6?本章的进一步阅读和来源

  • TypeScript 手册

  • TypeScript 语言规范

  • 章节“可调用值” in “JavaScript for impatient programmers”

评论

第五部分:处理模糊类型

原文:exploringjs.com/tackling-ts/pt_ambiguous-types.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:21 类型断言(与转换相关)

二十一、类型断言(与转换相关)

原文:exploringjs.com/tackling-ts/ch_type-assertions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 21.1?类型断言

    • 21.1.1?类型断言的替代语法

    • 21.1.2?示例:断言一个接口

    • 21.1.3?示例:断言索引签名

  • 21.2?与类型断言相关的构造

    • 21.2.1?非空断言操作符(后缀!

    • 21.2.2?明确赋值断言


本章讨论了 TypeScript 中的类型断言,它与其他语言中的类型转换相关,并通过as操作符执行。

21.1?类型断言

类型断言允许我们覆盖 TypeScript 为值计算的静态类型。这对于解决类型系统的限制非常有用。

类型断言与其他语言中的类型转换相关,但它们不会抛出异常,也不会在运行时执行任何操作(它们在静态上执行了一些最小的检查)。

const data: object = ['a', 'b', 'c']; // (A)

// @ts-expect-error: Property 'length' does not exist on type 'object'.
data.length; // (B)

assert.equal(
 (data as Array<string>).length, 3); // (C)

注释:

  • 在 A 行,我们将数组的类型扩展为 object

  • 在 B 行,我们看到这种类型不允许我们访问任何属性(详情)。

  • 在 C 行,我们使用类型断言(操作符as)告诉 TypeScript data 是一个数组。现在我们可以访问属性.length

类型断言是最后的手段,应尽量避免使用。它们(暂时地)移除了静态类型系统通常给我们的安全网。

请注意,在 A 行,我们还覆盖了 TypeScript 的静态类型。但我们是通过类型注释来实现的。这种覆盖方式比类型断言要安全得多,因为我们受到了更严格的约束:TypeScript 的类型必须可以赋值给注释的类型。

21.1.1?类型断言的替代语法

TypeScript 有一种替代的“尖括号”语法用于类型断言:

<Array<string>>data

我建议避免使用这种语法。它已经过时,并且与 React JSX 代码(在.tsx文件中)不兼容。

21.1.2?示例:断言一个接口

为了访问任意对象obj的属性.name,我们暂时将obj的静态类型更改为Named(A 行和 B 行)。

interface Named {
 name: string;
}
function getName(obj: object): string {
 if (typeof (obj as Named).name === 'string') { // (A)
 return (obj as Named).name; // (B)
 }
 return '(Unnamed)';
}
21.1.3?示例:断言索引签名

在以下代码(A 行)中,我们使用类型断言 as Dict,这样我们就可以访问一个值的推断类型为 object 的属性。也就是说,我们用静态类型 Dict 覆盖了静态类型 object

type Dict = {[k:string]: any};

function getPropertyValue(dict: unknown, key: string): any {
 if (typeof dict === 'object' && dict !== null && key in dict) {
 // %inferred-type: object
 dict;

 // @ts-expect-error: Element implicitly has an 'any' type because
 // expression of type 'string' can't be used to index type '{}'.
 // [...]
 dict[key];

 return (dict as Dict)[key]; // (A)
 } else {
 throw new Error();
 }
}

21.2?与类型断言相关的构造

21.2.1?非空断言操作符(后缀!

如果值的类型是包括 undefinednull 类型的联合类型,非空断言操作符(或非空断言操作符)会从联合类型中移除这些类型。我们告诉 TypeScript:“这个值不能是 undefinednull。” 因此,我们可以执行被这两个值类型阻止的操作 - 例如:

const theName = 'Jane' as (null | string);

// @ts-expect-error: Object is possibly 'null'.
theName.length;

assert.equal(
 theName!.length, 4); // OK
21.2.1.1?示例 - Map:.has()后的.get()

在使用 Map 方法.has()之后,我们知道 Map 具有给定的键。然而,.get()的结果并不反映这一知识,这就是为什么我们必须使用非空断言操作符的原因:

function getLength(strMap: Map<string, string>, key: string): number {
 if (strMap.has(key)) {
 // We are sure x is not undefined:
 const value = strMap.get(key)!; // (A)
 return value.length;
 }
 return -1;
}

我们可以在 Map 的值不能为 undefined 时避免使用 nullish 断言操作符。然后,可以通过检查 .get() 的结果是否为 undefined 来检测缺失的条目:

function getLength(strMap: Map<string, string>, key: string): number {
 // %inferred-type: string | undefined
 const value = strMap.get(key);
 if (value === undefined) { // (A)
 return -1;
 }

 // %inferred-type: string
 value;

 return value.length;
}
21.2.2?明确赋值断言

如果打开了strict property initialization,我们偶尔需要告诉 TypeScript 我们确实初始化了某些属性 - 尽管它认为我们没有。

这是一个例子,即使不应该,TypeScript 也会抱怨:

class Point1 {
 // @ts-expect-error: Property 'x' has no initializer and is not definitely
 // assigned in the constructor.
 x: number;

 // @ts-expect-error: Property 'y' has no initializer and is not definitely
 // assigned in the constructor.
 y: number;

 constructor() {
 this.initProperties();
 }
 initProperties() {
 this.x = 0;
 this.y = 0;
 }
}

如果我们在 A 行和 B 行使用definite assignment assertions(感叹号),错误就会消失:

class Point2 {
 x!: number; // (A)
 y!: number; // (B)
 constructor() {
 this.initProperties();
 }
 initProperties() {
 this.x = 0;
 this.y = 0;
 }
}

评论

二十二、类型守卫和断言函数

原文:exploringjs.com/tackling-ts/ch_type-guards-assertion-functions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 22.1 静态类型何时过于泛化?

    • 22.1.1 通过if和类型守卫缩小范围

    • 22.1.2 通过switch和类型守卫缩小范围

    • 22.1.3 类型过于泛化的更多情况

    • 22.1.4 类型unknown

  • 22.2 通过内置类型守卫缩小范围

    • 22.2.1 严格相等(===

    • 22.2.2 typeofinstanceofArray.isArray

    • 22.2.3 通过in运算符检查不同的属性

    • 22.2.4 检查共享属性的值(辨别联合)

    • 22.2.5 缩小点名

    • 22.2.6 缩小数组元素类型

  • 22.3 用户定义的类型守卫

    • 22.3.1 用户定义类型守卫的示例:isArrayWithInstancesOf()

    • 22.3.2 用户定义类型守卫的示例:isTypeof()

  • 22.4 断言函数

    • 22.4.1 TypeScript 对断言函数的支持

    • 22.4.2 断言布尔类型的参数:asserts ?cond?

    • 22.4.3 断言参数的类型:asserts ?arg? is ?type?

  • 22.5 快速参考:用户定义的类型守卫和断言函数

    • 22.5.1 用户定义类型守卫

    • 22.5.2 断言函数

  • 22.6 断言函数的替代方法

    • 22.6.1 技巧:强制转换

    • 22.6.2 技巧:抛出异常

  • 22.7 @hqoss/guards:带有类型守卫的库


在 TypeScript 中,一个值可能对于某些操作来说类型过于泛化,例如,联合类型。本章回答以下问题:

  • 类型的缩小是什么?

    • 剧透:缩小意味着将存储位置(例如变量或属性)的静态类型T更改为T的子集。例如,将类型null|string缩小为类型string通常很有用。
  • 类型守卫断言函数是什么,我们如何使用它们来缩小类型?

    • 剧透:typeofinstanceof是类型守卫。

22.1 当静态类型过于一般化时?

要看看静态类型如何过于一般化,请考虑以下函数 getScore()

assert.equal(
 getScore('*****'), 5);
assert.equal(
 getScore(3), 3);

getScore() 的骨架如下所示:

function getScore(value: number|string): number {
 // ···
}

getScore() 的主体中,我们不知道 value 的类型是 number 还是 string。在我们知道之前,我们无法真正处理 value

22.1.1 通过 if 和类型守卫缩小

解决方案是通过 typeof(A 行和 B 行)在运行时检查 value 的类型:

function getScore(value: number|string): number {
 if (typeof value === 'number') { // (A)
 // %inferred-type: number
 value;
 return value;
 }
 if (typeof value === 'string') { // (B)
 // %inferred-type: string
 value;
 return value.length;
 }
 throw new Error('Unsupported value: ' + value);
}

在本章中,我们将类型解释为值的集合。(有关此解释和另一种解释的更多信息,请参见[content not included]。)

在从 A 行和 B 行开始的 then-blocks 中,由于我们执行的检查,value 的静态类型发生了变化。我们现在正在处理原始类型 number|string 的子集。这种减小类型大小的方式称为缩小。检查 typeof 的结果和类似的运行时操作称为类型守卫

请注意,缩小不会改变 value 的原始类型,它只会在我们通过更多检查时变得更具体。

22.1.2 通过 switch 和类型守卫缩小

如果我们使用 switch 而不是 if,缩小也会起作用:

function getScore(value: number|string): number {
 switch (typeof value) {
 case 'number':
 // %inferred-type: number
 value;
 return value;
 case 'string':
 // %inferred-type: string
 value;
 return value.length;
 default:
 throw new Error('Unsupported value: ' + value);
 }
}
22.1.3 类型过于一般化的更多情况

这些是类型过于一般化的更多例子:

  • 可空类型:

    function func1(arg: null|string) {}
    function func2(arg: undefined|string) {}
    
  • 辨别联合:

    type Teacher = { kind: 'Teacher', teacherId: string };
    type Student = { kind: 'Student', studentId: string };
    type Attendee = Teacher | Student;
    
    function func3(attendee: Attendee) {}
    
  • 可选参数的类型:

    function func4(arg?: string) {
     // %inferred-type: string | undefined
     arg;
    }
    

请注意,这些类型都是联合类型!

22.1.4 类型 unknown

如果一个值具有类型 unknown,我们几乎无法对其进行任何操作,必须首先缩小其类型(A 行):

function parseStringLiteral(stringLiteral: string): string {
 const result: unknown = JSON.parse(stringLiteral);
 if (typeof result === 'string') { // (A)
 return result;
 }
 throw new Error('Not a string literal: ' + stringLiteral);
}

换句话说:类型 unknown 太一般化了,我们必须缩小它。在某种程度上,unknown 也是一个联合类型(所有类型的联合)。

22.2 通过内置类型守卫缩小

正如我们所见,类型守卫 是一种操作,根据其运行时是否满足某些条件,返回 truefalse。 TypeScript 的类型推断通过在结果为 true 时缩小操作数的静态类型来支持类型守卫。

22.2.1 严格相等 (===)

严格相等作为一种类型守卫:

function func(value: unknown) {
 if (value === 'abc') {
 // %inferred-type: "abc"
 value;
 }
}

对于一些联合类型,我们可以使用 === 来区分它们的组件:

interface Book {
 title: null | string;
 isbn: string;
}

function getTitle(book: Book) {
 if (book.title === null) {
 // %inferred-type: null
 book.title;
 return '(Untitled)';
 } else {
 // %inferred-type: string
 book.title;
 return book.title;
 }
}

使用 === 包括和 !=== 排除联合类型组件只有在该组件是单例类型(一个成员的集合)时才有效。类型 null 是一个单例类型。它唯一的成员是值 null

22.2.2 typeof, instanceof, Array.isArray

这些是三种常见的内置类型守卫:

function func(value: Function|Date|number[]) {
 if (typeof value === 'function') {
 // %inferred-type: Function
 value;
 }

 if (value instanceof Date) {
 // %inferred-type: Date
 value;
 }

 if (Array.isArray(value)) {
 // %inferred-type: number[]
 value;
 }
}

注意在 then-blocks 中 value 的静态类型是如何缩小的。

22.2.3 通过操作符 in 检查不同的属性

如果用于检查不同的属性,操作符 in 就是一种类型守卫:

type FirstOrSecond =
 | {first: string}
 | {second: string};

function func(firstOrSecond: FirstOrSecond) {
 if ('second' in firstOrSecond) {
 // %inferred-type: { second: string; }
 firstOrSecond;
 }
}

请注意以下检查将不起作用:

function func(firstOrSecond: FirstOrSecond) {
 // @ts-expect-error: Property 'second' does not exist on
 // type 'FirstOrSecond'. [...]
 if (firstOrSecond.second !== undefined) {
 // ···
 }
}

在这种情况下的问题是,如果不缩小,我们无法访问类型为 FirstOrSecond 的值的属性 .second

22.2.3.1 操作符 in 不会缩小非联合类型

遗憾的是,in 只能帮助我们处理联合类型:

function func(obj: object) {
 if ('name' in obj) {
 // %inferred-type: object
 obj;

 // @ts-expect-error: Property 'name' does not exist on type 'object'.
 obj.name;
 }
}
22.2.4 检查共享属性的值(辨别联合)

在辨别联合中,联合类型的组件具有一个或多个共同的属性,其值对于每个组件都是不同的。这些属性称为辨别者

检查辨别者的值是一种类型守卫:

type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;

function getId(attendee: Attendee) {
 switch (attendee.kind) {
 case 'Teacher':
 // %inferred-type: { kind: "Teacher"; teacherId: string; }
 attendee;
 return attendee.teacherId;
 case 'Student':
 // %inferred-type: { kind: "Student"; studentId: string; }
 attendee;
 return attendee.studentId;
 default:
 throw new Error();
 }
}

在前面的例子中,.kind 是一个辨别者:联合类型 Attendee 的每个组件都有这个属性,并且具有唯一的值。

if 语句和相等检查与 switch 语句类似:

function getId(attendee: Attendee) {
 if (attendee.kind === 'Teacher') {
 // %inferred-type: { kind: "Teacher"; teacherId: string; }
 attendee;
 return attendee.teacherId;
 } else if (attendee.kind === 'Student') {
 // %inferred-type: { kind: "Student"; studentId: string; }
 attendee;
 return attendee.studentId;
 } else {
 throw new Error();
 }
}
22.2.5 缩小点名

我们还可以缩小属性的类型(甚至是通过属性名称链访问的嵌套属性的类型):

type MyType = {
 prop?: number | string,
};
function func(arg: MyType) {
 if (typeof arg.prop === 'string') {
 // %inferred-type: string
 arg.prop; // (A)

 [].forEach((x) => {
 // %inferred-type: string | number | undefined
 arg.prop; // (B)
 });

 // %inferred-type: string
 arg.prop;

 arg = {};

 // %inferred-type: string | number | undefined
 arg.prop; // (C)
 }
}

让我们看看前面代码中的几个位置:

  • A 行:我们通过类型守卫缩小了 arg.prop 的类型。

  • B 行:回调可能会在很久以后执行(考虑异步代码),这就是为什么 TypeScript 在回调内部取消缩小。

  • C 行:前面的赋值也取消了缩小。

22.2.6 缩小数组元素类型
22.2.6.1 数组方法.every()不会缩小

如果我们使用.every()来检查所有数组元素是否非空,TypeScript 不会缩小mixedValues的类型(A 行):

const mixedValues: ReadonlyArray<undefined|null|number> =
 [1, undefined, 2, null];

if (mixedValues.every(isNotNullish)) {
 // %inferred-type: readonly (number | null | undefined)[]
 mixedValues; // (A)
}

请注意mixedValues必须是只读的。如果不是,那么在if语句中,对它的另一个引用将静态地允许我们将null推入mixedValues中。但这会使mixedValues的缩小类型不正确。

前面的代码使用了以下用户定义的类型守卫(稍后会详细介绍):

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
 return value !== undefined && value !== null;
}

NonNullable<Union>(A 行)是一个实用类型,它从联合类型Union中移除了undefinednull类型。

22.2.6.2 数组方法.filter()产生具有更窄类型的数组

.filter()产生具有更窄类型的数组(即,它实际上并没有缩小现有类型):

// %inferred-type: (number | null | undefined)[]
const mixedValues = [1, undefined, 2, null];

// %inferred-type: number[]
const numbers = mixedValues.filter(isNotNullish);

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
 return value !== undefined && value !== null;
}

遗憾的是,我们必须直接使用类型守卫函数-箭头函数与类型守卫是不够的:

// %inferred-type: (number | null | undefined)[]
const stillMixed1 = mixedValues.filter(
 x => x !== undefined && x !== null);

// %inferred-type: (number | null | undefined)[]
const stillMixed2 = mixedValues.filter(
 x => typeof x === 'number');

22.3 用户定义类型守卫

TypeScript 允许我们定义自己的类型守卫-例如:

function isFunction(value: unknown): value is Function {
 return typeof value === 'function';
}

返回类型value is Function类型断言的一部分。它是isFunction()的类型签名的一部分:

// %inferred-type: (value: unknown) => value is Function
isFunction;

用户定义的类型守卫必须始终返回布尔值。如果isFunction(x)返回true,TypeScript 会将实际参数x的类型缩小为Function

function func(arg: unknown) {
 if (isFunction(arg)) {
 // %inferred-type: Function
 arg; // type is narrowed
 }
}

请注意,TypeScript 不关心我们如何计算用户定义类型守卫的结果。这给了我们很大的自由度,关于我们使用的检查。例如,我们可以将isFunction()实现为以下方式:

function isFunction(value: any): value is Function {
 try {
 value(); // (A)
 return true;
 } catch {
 return false;
 }
}

遗憾的是,我们必须对参数value使用类型any,因为类型unknown不允许我们在 A 行进行函数调用。

22.3.1 用户定义类型守卫的示例:isArrayWithInstancesOf()
/**
 * This type guard for Arrays works similarly to `Array.isArray()`,
 * but also checks if all Array elements are instances of `T`.
 * As a consequence, the type of `arr` is narrowed to `Array<T>`
 * if this function returns `true`.
 * 
 * Warning: This type guard can make code unsafe – for example:
 * We could use another reference to `arr` to add an element whose
 * type is not `T`. Then `arr` doesn’t have the type `Array<T>`
 * anymore.
 */
function isArrayWithInstancesOf<T>(
 arr: any, Class: new (...args: any[])=>T)
 : arr is Array<T>
{
 if (!Array.isArray(arr)) {
 return false;
 }
 if (!arr.every(elem => elem instanceof Class)) {
 return false;
 }

 // %inferred-type: any[]
 arr; // (A)

 return true;
}

在 A 行,我们可以看到arr的推断类型不是Array<T>,但我们的检查确保它目前是。这就是为什么我们可以返回true。TypeScript 相信我们,并在我们使用isArrayWithInstancesOf()时将其缩小为Array<T>

const value: unknown = {};
if (isArrayWithInstancesOf(value, RegExp)) {
 // %inferred-type: RegExp[]
 value;
}
22.3.2 用户定义类型守卫的示例:isTypeof()
22.3.2.1 第一次尝试

这是在 TypeScript 中实现typeof的第一次尝试:

/**
 * An implementation of the `typeof` operator.
 */
function isTypeof<T>(value: unknown, prim: T): value is T {
 if (prim === null) {
 return value === null;
 }
 return value !== null && (typeof prim) === (typeof value);
}

理想情况下,我们可以通过字符串指定value的预期类型(即typeof的结果之一)。但是,然后我们将不得不从该字符串中派生类型T,并且如何做到这一点并不是立即明显的(正如我们很快将看到的那样)。作为一种解决方法,我们通过T的成员prim来指定T

const value: unknown = {};
if (isTypeof(value, 123)) {
 // %inferred-type: number
 value;
}
22.3.2.2 使用重载

更好的解决方案是使用重载(有几种情况被省略):

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof(value: any, typeString: 'boolean'): value is boolean;
function isTypeof(value: any, typeString: 'number'): value is number;
function isTypeof(value: any, typeString: 'string'): value is string;
function isTypeof(value: any, typeString: string): boolean {
 return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'boolean')) {
 // %inferred-type: boolean
 value;
}

(这个方法是由Nick Fisher提出的。)

22.3.2.3 使用接口作为类型映射

另一种方法是使用接口作为从字符串到类型的映射(有几种情况被省略):

interface TypeMap {
 boolean: boolean;
 number: number;
 string: string;
}

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof<T extends keyof TypeMap>(value: any, typeString: T)
: value is TypeMap[T] {
 return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'string')) {
 // %inferred-type: string
 value;
}

(这个方法是由Ran Lottem提出的。)

22.4 断言函数

断言函数检查其参数是否满足某些条件,如果不满足则抛出异常。例如,许多语言支持的一个断言函数是assert()assert(cond)如果布尔条件condfalse,则抛出异常。

在 Node.js 上,assert()通过内置模块assert支持。以下代码在 A 行中使用了它:

import assert from 'assert';
function removeFilenameExtension(filename: string) {
 const dotIndex = filename.lastIndexOf('.');
 assert(dotIndex >= 0); // (A)
 return filename.slice(0, dotIndex);
}
22.4.1 TypeScript 对断言函数的支持

如果我们使用断言签名作为返回类型标记这样的函数,TypeScript 的类型推断将特别支持断言函数。就函数的返回方式和返回内容而言,断言签名等同于void。但它还会触发缩小。

有两种断言签名:

  • 断言布尔参数:asserts ?cond?

  • 断言参数的类型:asserts ?arg? is ?type?

22.4.2 断言布尔参数:asserts ?cond?

在下面的例子中,断言签名asserts condition表示参数condition必须为true。否则,将抛出异常。

function assertTrue(condition: boolean): asserts condition {
 if (!condition) {
 throw new Error();
 }
}

这就是assertTrue()导致缩小的方式:

function func(value: unknown) {
 assertTrue(value instanceof Set);

 // %inferred-type: Set<any>
 value;
}

我们使用参数value instanceof Set类似于类型守卫,但是false触发异常,而不是跳过条件语句的一部分。

22.4.3?断言参数的类型:asserts ?arg? is ?type?

在下面的例子中,断言签名asserts value is number表示参数value必须具有类型number。否则,将抛出异常。

function assertIsNumber(value: any): asserts value is number {
 if (typeof value !== 'number') {
 throw new TypeError();
 }
}

这次,调用断言函数会缩小其参数的类型:

function func(value: unknown) {
 assertIsNumber(value);

 // %inferred-type: number
 value;
}
22.4.3.1?示例断言函数:向对象添加属性

函数addXY()会向现有对象添加属性,并相应地更新它们的类型:

function addXY<T>(obj: T, x: number, y: number)
: asserts obj is (T & { x: number, y: number }) {
 // Adding properties via = would be more complicated...
 Object.assign(obj, {x, y});
}

const obj = { color: 'green' };
addXY(obj, 9, 4);

// %inferred-type: { color: string; } & { x: number; y: number; }
obj;

交集类型S & T具有类型S和类型T的属性。

22.5?快速参考:用户定义的类型守卫和断言函数

22.5.1?用户定义的类型守卫
function isString(value: unknown): value is string {
 return typeof value === 'string';
}
  • 类型谓词:value is string

  • 结果:boolean

22.5.2?断言函数
22.5.2.1?断言签名:asserts ?cond?
function assertTrue(condition: boolean): asserts condition {
 if (!condition) {
 throw new Error(); // assertion error
 }
}
  • 断言签名:asserts condition

  • 结果:void,异常

22.5.2.2?断言签名:asserts ?arg? is ?type?
function assertIsString(value: unknown): asserts value is string {
 if (typeof value !== 'string') {
 throw new Error(); // assertion error
 }
}
  • 断言签名:asserts value is string

  • 结果:void,异常

22.6?断言函数的替代方法

22.6.1?技术:强制转换

断言函数会缩小现有值的类型。强制转换函数会返回具有新类型的现有值 - 例如:

function forceNumber(value: unknown): number {
 if (typeof value !== 'number') {
 throw new TypeError();
 }
 return value;
}

const value1a: unknown = 123;
// %inferred-type: number
const value1b = forceNumber(value1a);

const value2: unknown = 'abc';
assert.throws(() => forceNumber(value2));

相应的断言函数如下所示:

function assertIsNumber(value: unknown): asserts value is number {
 if (typeof value !== 'number') {
 throw new TypeError();
 }
}

const value1: unknown = 123;
assertIsNumber(value1);
// %inferred-type: number
value1;

const value2: unknown = 'abc';
assert.throws(() => assertIsNumber(value2));

强制转换是一种多用途的技术,除了断言函数的用途之外还有其他用途。例如,我们可以转换:

  • 从易于编写的输入格式(比如 JSON 模式)

  • 转换为易于在代码中使用的输出格式。

有关更多信息,请参见[content not included]。

22.6.2?技术:抛出异常

考虑以下代码:

function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
 if (strMap.has(key)) {
 const value = strMap.get(key);

 // %inferred-type: string | undefined
 value; // before type check

 // We know that value can’t be `undefined`
 if (value === undefined) { // (A)
 throw new Error();
 }

 // %inferred-type: string
 value; // after type check

 return value.length;
 }
 return -1;
}

我们也可以使用断言函数来代替从 A 行开始的if语句:

assertNotUndefined(value);

如果我们不想编写这样的函数,抛出异常是一个快速的替代方法。与调用断言函数类似,这种技术也会更新静态类型。

22.7?@hqoss/guards:带有类型守卫的库

@hqoss/guards提供了一组用于 TypeScript 的类型守卫 - 例如:

  • 基本类型:isBoolean()isNumber(),等等。

  • 特定类型:isObject()isNull()isFunction(),等等。

  • 各种检查:isNonEmptyArray()isInteger(),等等。

评论