TypeScript的核心原则之一是,类型检查的重点是值的形状。这有时被称为 “鸭型 “或 “结构子类型”。在TypeScript中,接口扮演了命名这些类型的角色,并且是在你的代码中定义合同以及在你的项目之外定义合同的一种强大的方式。

我们的第一个接口

要了解接口的工作原理,最简单的方法是从一个简单的例子开始。

  1. function printLabel(labeledObj: { label: string }) {
  2. console.log(labeledObj.label);
  3. }
  4. let myObj = { size: 10, label: "Size 10 Object" };
  5. printLabel(myObj);

类型检查器检查对printLabel的调用。printLabel函数有一个单一的参数,要求传入的对象有一个类型为string的属性label。注意,我们的对象实际上有比这更多的属性,但编译器只检查至少需要的那些属性是存在的,并且与所需的类型相匹配。有一些情况下,TypeScript并没有那么宽松,我们稍后会介绍。

我们可以再写一次同样的例子,这次用一个接口来描述拥有标签属性是字符串的要求。

  1. interface LabeledValue {
  2. label: string;
  3. }
  4. function printLabel(labeledObj: LabeledValue) {
  5. console.log(labeledObj.label);
  6. }
  7. let myObj = { size: 10, label: "Size 10 Object" };
  8. printLabel(myObj);

LabeledValue接口是我们现在可以用来描述前面例子中需求的名称。它仍然代表着有一个叫做label的单一属性,它的类型是string。请注意,我们没有像在其他语言中那样,必须明确地说我们传递给printLabel的对象实现了这个接口。在这里,只有形状才是最重要的。如果我们传递给函数的对象符合所列出的要求,那么它就是被允许的。

值得指出的是,类型检查器并不要求这些属性有任何的顺序,只要求接口所要求的属性是存在的,并且具有所要求的类型。

可选属性

并非一个界面的所有属性都是必需的。有些属性在某些条件下存在,或者根本就不存在。这些可选的属性在创建类似 “选项袋 “的模式时很受欢迎,在这种模式下,你将一个对象传递给一个只有几个属性被填充的函数。

下面是这种模式的一个例子。

  1. interface SquareConfig {
  2. color?: string;
  3. width?: number;
  4. }
  5. function createSquare(config: SquareConfig): { color: string; area: number } {
  6. let newSquare = { color: "white", area: 100 };
  7. if (config.color) {
  8. newSquare.color = config.color;
  9. }
  10. if (config.width) {
  11. newSquare.area = config.width * config.width;
  12. }
  13. return newSquare;
  14. }
  15. let mySquare = createSquare({ color: "black" });

带有可选属性的接口的写法与其他接口类似,在声明中,每个可选属性在属性名的末尾用一个?

可选属性的好处是,你可以描述这些可能可用的属性,同时还可以防止使用不属于接口的属性。例如,如果我们在createSquare中打错了颜色属性的名称,我们会得到一个错误信息让我们知道。

  1. interface SquareConfig {
  2. color?: string;
  3. width?: number;
  4. }
  5. function createSquare(config: SquareConfig): { color: string; area: number } {
  6. let newSquare = { color: "white", area: 100 };
  7. if (config.clor) {
  8. Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?
  9. // Error: Property 'clor' does not exist on type 'SquareConfig'
  10. newSquare.color = config.clor;
  11. Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?
  12. }
  13. if (config.width) {
  14. newSquare.area = config.width * config.width;
  15. }
  16. return newSquare;
  17. }
  18. let mySquare = createSquare({ color: "black" });

只读属性

有些属性只有在第一次创建对象时才可以修改。你可以在属性名称前加上readonly来指定。

  1. interface Point {
  2. readonly x: number;
  3. readonly y: number;
  4. }

您可以通过赋值一个对象文字来构造一个点。赋值后,x和y不能被改变。

  1. let p1: Point = { x: 10, y: 20 };
  2. p1.x = 5; // error!
  3. Cannot assign to 'x' because it is a read-only property.

TypeScript带有一个ReadonlyArray类型,它和Array一样,去掉了所有的突变方法,所以你可以确保你在创建后不会改变你的数组。

  1. let a: number[] = [1, 2, 3, 4];
  2. let ro: ReadonlyArray<number> = a;
  3. ro[0] = 12; // error!
  4. Index signature in type 'readonly number[]' only permits reading.
  5. ro.push(5); // error!
  6. Property 'push' does not exist on type 'readonly number[]'.
  7. ro.length = 100; // error!
  8. Cannot assign to 'length' because it is a read-only property.
  9. a = ro; // error!
  10. The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.

在代码段的最后一行,你可以看到,即使将整个ReadonlyArray赋值回一个普通数组也是非法的。不过,你仍然可以通过类型断言来覆盖它。

  1. let a: number[] = [1, 2, 3, 4];
  2. let ro: ReadonlyArray<number> = a;
  3. a = ro as number[];

readonly vs const
记住是使用readonly还是const的最简单的方法是询问你是在一个变量上还是在一个属性上使用它。变量使用const,而属性使用readonly。

超额财产检查

在我们第一个使用接口的例子中,TypeScript让我们将{ size: number; label: string; }传递给只期望有{ label: string; }的东西。我们还刚刚学习了可选属性,以及它们在描述所谓的 “选项袋 “时是如何有用的。

然而,天真地将这两者结合起来,会让一个错误悄然而至。例如,以我们上一个使用createSquare的例子为例。

  1. interface SquareConfig {
  2. color?: string;
  3. width?: number;
  4. }
  5. function createSquare(config: SquareConfig): { color: string; area: number } {
  6. return {
  7. color: config.color || "red",
  8. area: config.width ? config.width * config.width : 20,
  9. };
  10. }
  11. let mySquare = createSquare({ colour: "red", width: 100 });
  12. Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
  13. Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?

请注意,createSquare给定的参数拼写的是color而不是color。在纯正的JavaScript中,这种事情是默默失败的。

你可以说这个程序的类型是正确的,因为宽度属性是兼容的,没有颜色属性存在,额外的颜色属性也是无关紧要的。

然而,TypeScript的立场是,这段代码中可能存在一个bug。对象文字得到了特殊的处理,当把它们赋值给其他变量,或者把它们作为参数传递时,会进行多余的属性检查。如果一个对象文字有任何 “目标类型 “没有的属性,你会得到一个错误。

  1. let mySquare = createSquare({ colour: "red", width: 100 });
  2. Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
  3. Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?

绕过这些检查其实很简单。最简单的方法就是使用类型断言。

  1. let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

然而,如果你确定对象可以有一些额外的属性,以某种特殊的方式使用,那么更好的方法可能是添加一个字符串索引签名。如果SquareConfig可以有上述类型的颜色和宽度属性,但也可以有任何数量的其他属性,那么我们可以这样定义它。

  1. interface SquareConfig {
  2. color?: string;
  3. width?: number;
  4. [propName: string]: any;
  5. }

我们稍后会讨论索引签名,但这里我们要说的是,一个SquareConfig可以有任何数量的属性,只要它们不是颜色或宽度,它们的类型就不重要。

最后一个绕过这些检查的方法,可能有点令人惊讶,就是将对象分配给另一个变量。因为squareOptions不会接受多余的属性检查, 编译器不会给你一个错误.

  1. let squareOptions = { colour: "red", width: 100 };
  2. let mySquare = createSquare(squareOptions);

只要你在squareOptions和SquareConfig之间有一个共同的属性,上面的变通方法就可以用。在这个例子中,它是属性宽度。然而,如果变量没有任何共同的对象属性,它就会失败。比如说

  1. let squareOptions = { colour: "red" };
  2. let mySquare = createSquare(squareOptions);
  3. Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.

请记住,对于像上面这样的简单代码,你可能不应该试图 “绕过 “这些检查。对于有方法并保持状态的更复杂的对象字面,你可能需要记住这些技术,但大多数过度属性错误实际上是bug。这意味着,如果你遇到了像选项袋这样的多余属性检查问题,你可能需要修改一些类型声明。在这个例子中,如果将一个同时具有颜色或颜色属性的对象传递给createSquare是可以的,你应该修正SquareConfig的定义来反映这一点。

函数类型

接口能够描述JavaScript对象可以采取的各种形状。除了用属性描述对象之外,接口还能够描述函数类型。

为了用接口描述一个函数类型,我们给接口一个调用签名。这就像一个函数声明一样,只给出参数列表和返回类型。参数列表中的每个参数都需要同时给出名称和类型。

  1. interface SearchFunc {
  2. (source: string, subString: string): boolean;
  3. }

一旦定义好,我们就可以像使用其他接口一样使用这个函数类型接口。在这里,我们将展示如何创建一个函数类型的变量,并给它分配一个相同类型的函数值。

  1. let mySearch: SearchFunc;
  2. mySearch = function (source: string, subString: string) {
  3. let result = source.search(subString);
  4. return result > -1;
  5. };

为了使函数类型正确地进行类型检查,参数的名称不需要匹配。例如,我们可以把上面的例子写成这样。

  1. let mySearch: SearchFunc;
  2. mySearch = function (src: string, sub: string): boolean {
  3. let result = src.search(sub);
  4. return result > -1;
  5. };

函数参数一次检查一个,每个对应参数位置的类型相互检查。如果你完全不想指定类型,TypeScript的上下文类型可以推断出参数类型,因为函数值是直接分配给SearchFunc类型的变量。在这里,我们的函数表达式的返回类型也是由它返回的值暗示的(这里是false和true)。

  1. let mySearch: SearchFunc;
  2. mySearch = function (src, sub) {
  3. let result = src.search(sub);
  4. return result > -1;
  5. };

如果函数表达式返回的是数字或字符串,类型检查器就会出错,表明返回类型与SearchFunc接口中描述的返回类型不匹配。

  1. let mySearch: SearchFunc;
  2. mySearch = function (src, sub) {
  3. Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
  4. Type 'string' is not assignable to type 'boolean'.
  5. let result = src.search(sub);
  6. return "string";
  7. };

可索引类型

与我们如何使用接口来描述函数类型类似,我们也可以描述我们可以 “索引到 “的类型,比如a[10],或者ageMap[“daniel”]。可索引类型有一个索引签名,它描述了我们可以用来索引到对象的类型,以及索引时相应的返回类型。我们举个例子。

  1. interface StringArray {
  2. [index: number]: string;
  3. }
  4. let myArray: StringArray;
  5. myArray = ["Bob", "Fred"];
  6. let myStr: string = myArray[0];

上面,我们有一个StringArray接口,它有一个索引签名。这个索引签名说明,当StringArray被一个数字索引时,它将返回一个字符串。

支持的索引签名有两种类型:字符串和数字。可以同时支持这两种类型的索引器,但从数字索引器返回的类型必须是从字符串索引器返回的类型的一个子类型。这是因为当使用数字进行索引时,JavaScript实际上会在索引成对象之前将其转换为字符串。这意味着用100(数字)做索引和用 “100”(字符串)做索引是一回事,所以两者需要保持一致。

  1. interface Animal {
  2. name: string;
  3. }
  4. interface Dog extends Animal {
  5. breed: string;
  6. }
  7. // Error: indexing with a numeric string might get you a completely separate type of Animal!
  8. interface NotOkay {
  9. [x: number]: Animal;
  10. Numeric index type 'Animal' is not assignable to string index type 'Dog'.
  11. [x: string]: Dog;
  12. }

虽然字符串索引签名是描述 “字典 “模式的有力方式,但它们也强制要求所有属性与其返回类型相匹配。这是因为字符串索引声明obj.property也可以作为obj[“property”]。在下面的例子中,name的类型与字符串索引的类型不匹配,类型检查器给出了一个错误。

  1. interface NumberDictionary {
  2. [index: string]: number;
  3. length: number; // ok, length is a number
  4. name: string; // error, the type of 'name' is not a subtype of the indexer
  5. Property 'name' of type 'string' is not assignable to string index type 'number'.
  6. }

但是,如果索引签名是属性类型的联合,则可以接受不同类型的属性。

  1. interface NumberOrStringDictionary {
  2. [index: string]: number | string;
  3. length: number; // ok, length is a number
  4. name: string; // ok, name is a string
  5. }

最后,你可以将索引签名改为只读,以防止向其索引分配。

  1. interface ReadonlyStringArray {
  2. readonly [index: number]: string;
  3. }
  4. let myArray: ReadonlyStringArray = ["Alice", "Bob"];
  5. myArray[2] = "Mallory"; // error!
  6. Index signature in type 'ReadonlyStringArray' only permits reading.

你不能设置myArray[2],因为索引签名是readonly。

类型

实现一个接口

在像C#和Java这样的语言中,接口最常见的用法之一,就是显式地强制一个类满足一个特定的合同,在TypeScript中也是可能的。

  1. interface ClockInterface {
  2. currentTime: Date;
  3. }
  4. class Clock implements ClockInterface {
  5. currentTime: Date = new Date();
  6. constructor(h: number, m: number) {}
  7. }

你也可以在一个接口中描述在类中实现的方法,就像我们在下面的例子中对setTime所做的那样。

  1. interface ClockInterface {
  2. currentTime: Date;
  3. setTime(d: Date): void;
  4. }
  5. class Clock implements ClockInterface {
  6. currentTime: Date = new Date();
  7. setTime(d: Date) {
  8. this.currentTime = d;
  9. }
  10. constructor(h: number, m: number) {}
  11. }

接口描述的是类的公共面,而不是公共面和私有面。这禁止你使用它们来检查一个类的私有侧实例是否也有特定的类型。

类的静态侧和实例侧的区别
在处理类和接口时,记住一个类有两种类型是有帮助的:静态方面的类型和实例方面的类型。你可能会注意到,如果你创建了一个具有构造签名的接口,并试图创建一个实现这个接口的类,你会得到一个错误。

  1. interface ClockConstructor {
  2. new (hour: number, minute: number);
  3. }
  4. class Clock implements ClockConstructor {
  5. Class 'Clock' incorrectly implements interface 'ClockConstructor'.
  6. Type 'Clock' provides no match for the signature 'new (hour: number, minute: number): any'.
  7. currentTime: Date;
  8. constructor(h: number, m: number) {}
  9. }

这是因为当一个类实现一个接口时,只检查该类的实例侧。由于构造函数位于静态侧,所以它不包括在这个检查中。

相反,你需要直接与类的静态侧合作。在这个例子中,我们定义了两个接口,ClockConstructor用于构造函数,ClockInterface用于实例方法。然后,为了方便起见,我们定义了一个构造函数createClock,用来创建传递给它的类型的实例。

  1. interface ClockConstructor {
  2. new (hour: number, minute: number): ClockInterface;
  3. }
  4. interface ClockInterface {
  5. tick(): void;
  6. }
  7. function createClock(
  8. ctor: ClockConstructor,
  9. hour: number,
  10. minute: number
  11. ): ClockInterface {
  12. return new ctor(hour, minute);
  13. }
  14. class DigitalClock implements ClockInterface {
  15. constructor(h: number, m: number) {}
  16. tick() {
  17. console.log("beep beep");
  18. }
  19. }
  20. class AnalogClock implements ClockInterface {
  21. constructor(h: number, m: number) {}
  22. tick() {
  23. console.log("tick tock");
  24. }
  25. }
  26. let digital = createClock(DigitalClock, 12, 17);
  27. let analog = createClock(AnalogClock, 7, 32);

因为createClock的第一个参数是ClockConstructor类型,所以在createClock(AnalogClock, 7, 32)中,它会检查AnalogClock的构造函数签名是否正确。

另一个简单的方法是使用类表达式。

  1. interface ClockConstructor {
  2. new (hour: number, minute: number): ClockInterface;
  3. }
  4. interface ClockInterface {
  5. tick(): void;
  6. }
  7. const Clock: ClockConstructor = class Clock implements ClockInterface {
  8. constructor(h: number, m: number) {}
  9. tick() {
  10. console.log("beep beep");
  11. }
  12. };
  13. let clock = new Clock(12, 17);
  14. clock.tick();

扩展接口

和类一样,接口也可以相互扩展。这允许你将一个接口的成员复制到另一个接口中,这让你在如何将你的接口分离成可重用的组件时更加灵活。

  1. interface Shape {
  2. color: string;
  3. }
  4. interface Square extends Shape {
  5. sideLength: number;
  6. }
  7. let square = {} as Square;
  8. square.color = "blue";
  9. square.sideLength = 10;

一个接口可以扩展多个接口,形成所有接口的组合。

  1. interface Shape {
  2. color: string;
  3. }
  4. interface PenStroke {
  5. penWidth: number;
  6. }
  7. interface Square extends Shape, PenStroke {
  8. sideLength: number;
  9. }
  10. let square = {} as Square;
  11. square.color = "blue";
  12. square.sideLength = 10;
  13. square.penWidth = 5.0;

混合型

正如我们前面提到的,接口可以描述现实世界中存在的丰富的JavaScript类型。由于JavaScript的动态和灵活的特性,你可能偶尔会遇到一个对象,它作为上面描述的一些类型的组合工作。

其中一个这样的例子是一个既作为函数又作为对象的对象,并带有附加属性。

  1. interface Counter {
  2. (start: number): string;
  3. interval: number;
  4. reset(): void;
  5. }
  6. function getCounter(): Counter {
  7. let counter = function (start: number) {} as Counter;
  8. counter.interval = 123;
  9. counter.reset = function () {};
  10. return counter;
  11. }
  12. let c = getCounter();
  13. c(10);
  14. c.reset();
  15. c.interval = 5.0;

当与第三方JavaScript交互时,你可能需要使用类似上面的模式来完整描述类型的形状。

扩展类的接口

当一个接口类型扩展一个类类型时,它继承了该类的成员,但不继承它们的实现。这就好比接口声明了类的所有成员,却没有提供实现。接口甚至可以继承基类的私有和保护成员。这意味着,当你创建一个扩展了一个具有私有成员或保护成员的类的接口时,该接口类型只能由该类或其子类实现。

当你有一个庞大的继承层次结构,但又想指定你的代码只与具有某些属性的子类一起工作时,这很有用。子类除了从基类继承外,不必有任何关系。例如

  1. class Control {
  2. private state: any;
  3. }
  4. interface SelectableControl extends Control {
  5. select(): void;
  6. }
  7. class Button extends Control implements SelectableControl {
  8. select() {}
  9. }
  10. class TextBox extends Control {
  11. select() {}
  12. }
  13. class ImageControl implements SelectableControl {
  14. Class 'ImageControl' incorrectly implements interface 'SelectableControl'.
  15. Types have separate declarations of a private property 'state'.
  16. private state: any;
  17. select() {}
  18. }

在上面的例子中,SelectableControl包含了Control的所有成员,包括私有的state属性。由于state是一个私有成员,所以只有Control的子孙才有可能实现SelectableControl,这是因为只有Control的子孙才会有一个源自同一个声明的state私有成员,这也是Control的要求。这是因为只有Control的子孙才会有一个源自同一个声明的state私有成员,这是私有成员兼容的要求。

在Control类中,可以通过SelectableControl的实例来访问状态私有成员。实际上,一个SelectableControl的作用就像一个已知有选择方法的Control。Button和TextBox类是SelectableControl的子类型(因为它们都继承自Control并有一个选择方法)。ImageControl类有它自己的状态私有成员,而不是扩展Control,所以它不能实现SelectableControl。