接口

状态: 初稿

介绍

TypeScript 的一个核心理念是: 类型检查只关注值的形体.
有时我们叫它鸭式类型, 或结构化子类型.
在 TypeScript 中, 接口不仅起着命名类型的作用, 同时也定义项目内外一致遵守的约定.

接口初探

我们先通过一个简单的例子来看接口是如何工作的:

1
2
3
4
5
6
function printLabel(labeledObj: { label: string }) {
console.log(labeledObj.label);
}

let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

类型检查器检查对方法 printLabel 的调用.
这个函数有一个参数, 它要求实参有一个类型为 string 的属性 label.
你可以看到我们的对象实际上还有一个属性 size, 但是编译器只关心必要属性存在, 类型一致.
编译器并不总是这么宽松, 后期再讲.

重写我们的例子, 用接口来约定函数参数须满足的条件:

1
2
3
4
5
6
7
8
9
10
interface LabeledValue {
label: string;
}

function printLabel(labeledObj: LabeledValue) {
console.log(labeledObj.label);
}

let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

我们现在可以用接口名 LabeledValue 来描述上例函数参数的要求.
它同样表示函数参数要有一个类型为 string 的属性 label.
注意跟其他语言不一样, 我们没必要显式声明实参 printLabel 实现了接口.
TypeScript 只关注形体. 如果实参满足接口描述的形体, 函数调用就是有效的.

类型检查器对属性的顺序亦不做要求, 重要的是接口属性已呈现, 类型一一对应.

可选属性

不是每个接口属性都被实际需要.
有的属性存在与否取决于特定条件.
“可选” 属性广泛应用于只给函数参数提供一部分属性的 “option bags” 等模式.

让我们看一个应用该模式的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface SquareConfig {
color?: string;
width?: number;
}

function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {color: "white", area: 100};
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}

let mySquare = createSquare({color: "black"});

定义中紧跟属性名的问号 ? 表明接口属性是 “可选” 的.

可选属性的优势是你在避免访问接口不存在属性的同时声明偶尔可用的属性.
例如: 如果我们错误输入 createSquare 的属性 color, 编译器会提醒:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface SquareConfig {
color?: string;
width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
let newSquare = {color: "white", area: 100};
if (config.clor) {
// Error: Property 'clor' does not exist on type 'SquareConfig'
newSquare.color = config.clor;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}

let mySquare = createSquare({color: "black"});

只读属性

有的属性应该只能在创建时修改.
readonly 关键字声明一个 “只读” 属性:

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

你可以赋值对象字面量创建一个 Point 变量.
x, y 在赋值后无法再改变.

1
2
let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!

TypeScript 附带的 ReadonlyArray<T>Array<T> 所有可写方法移除, 你可以确保新创建的数组一直保持原状.

1
2
3
4
5
6
let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

代码片段最后一行, 你可以看到把 ReadonlyArray<T> 赋值给普通数组也是非法的.
你可以用类型担保打破该限制:

1
a = ro as number[];

readonly 对比 const

选择 readonlyconst 的原则很容易记住, 问问自己是要创建变量还是属性.
const 修饰变量, readonly 修饰属性.

超量属性检查

在第一个运用接口的例子中, TypeScript 允许你把 { size: number; label: string; } 传递给 { label: string; }.
我们也学习了可选属性, 及它们在描述 “option bags” 模式方面的才能.

然而, 不假思索组合二者有犯错可能. 以我们上个例子作为演示:

1
2
3
4
5
6
7
8
9
10
interface SquareConfig {
color?: string;
width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
// ...
}

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

注意到我们把 SqureConfig.color 错拼成 colour.
原始 JavaScript 不能感知这类错误.

该程序语法正确无误, width 属性是相容的, 缺失可选属性 color, 额外 colour 属性并不重要.

然而, TypeScript 的立场是: 你的程序中可能隐藏 bug.
把对象字面量赋值给其他变量, 传递给函数参数参数, 都受到特殊对待 — 要通过超量属性检查.
当一个对象字面量含有目的类型不具备的属性, 你会收到错误提示:

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

绕过超量属性检查并不复杂.
最直接的选项是类型担保:

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

话说回来, 如果真的有表示额外属性的需要, 添加一个字符串索引签名会更好.
如下定义 SquareConfig, 使它包含的额外属性数量不受限制:

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

我们很快会认识索引签名, 在这里, 它的意思是除 color, width 外, SquareConfig 可以有任何属性, 它们的类型并不重要.

还有一种方法绕过检查, 很是巧妙 — 先把字面量赋值给一个变量.
编译器不会对变量 squareOptions 执行超量属性检查, 这错误也就消除了.

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

只要 squareOptionsSquareConfig 有共有属性, 上例就能编译.
在这个例子中, 共有属性是 width. 而下例恰恰相反, 因为 squareOptionsSquareConfig 没有任何共有属性:

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

注意就算是修改以上简单代码, 也最好不要想办法绕过检查.
在更复杂的, 包含方法和状态对象字面量前, 永远把我们介绍的方法当成思维训练, 大量超量属性问题都证明是实实在在的 bug.
假如你的 option bags 遇到超量属性问题, 首先考察能否修改一部分类型定义.
对于我们的例子, 如果 colorcolour 都可接受, 我们应该修改 SquareConfig 的定义以反映该意愿.

函数类型

接口可以描述多种供 JavaScript 对象参照的形体.
除了描述对象(具备哪些属性), 接口也能描述函数.

要用接口描述函数, 我们把函数的调用签名放到接口中.
调用签名只有参数列表和返回值类型, 像一个没有实现的函数. 每个参数都需要参数名和类型.

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

一旦定义, 我们就可以使用这个函数类型接口.
这里, 我们演示创建一个函数类型变量, 再赋值一个同类型函数给它.

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

实际函数的参数名不需要与调用签名匹配.
我们也可以如下书写以上片段:

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

编译器按序造访每个函数参数, 确认相对参数位置类型正确匹配.
这里实际函数直接赋值的变量 mySearch 类型确定, 如果你懒得指定参数类型, TypeScript 上下文类型推导功能可以替你推出你函数每个参数的类型.
函数返回值类型同样可以通过返回表达式(false 或 true)推导.

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

假如函数返回 numberstring, 类型检查器会敏锐地指出返回值类型与接口描述不匹配.

1
2
3
4
5
6
7
8
let mySearch: SearchFunc;

// error: Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
// Type 'string' is not assignable to type 'boolean'.
mySearch = function(src, sub) {
let result = src.search(sub);
return "string";
};

可索引类型

接口还可以表明一个类型是”可索引”的, 表达式 a[10], ageMap["daniel"] 只适用于可索引对象.
可索引类型都有一个索引签名, 索引签名象征索引类型的特殊”身份”, 声明索引操作的返回值类型.
请看下例:

1
2
3
4
5
6
7
8
interface StringArray {
[index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

上面, StringArray 接口有一个索引签名.
它的索引签名声明如果用 number 去索引 StringArray, 它返回一个 string.

有两种索引签名类型: string 型number 型 (译注: “型”也对应它们的参数类型).
两种索引签名可以共存, 但有一个前提: number 型索引签名的返回值必须是 string 型索引签名返回值的子类型.
这是因为在操作正式提交前, TypeScript 自动将 number 型索引参数转换成 string.
即: 用 100 (number) 索引等同于用 "100" (string) 索引, 有必要维持两个签名一致性.

1
2
3
4
5
6
7
8
9
10
11
12
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}

// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}

string 型索引签名是描述”字典”模式的有力途径, 它强调所有属性满足自己的返回值类型.
因为 string 型索引签名表示所有 obj.property 也以表达式 obj["property"] 呈现.
下例, 属性 name 的类型不满足 string 型索引签名返回值类型, 于是类型检查器报错:

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

如果 string 型索引签名返回值类型是一个联合, 你就可以定义有限种不同类型的属性了.

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

最后, 用 readonly 修饰索引签名防止索引表达式成为左值:

1
2
3
4
5
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!

索引签名是”只读”的, 你不能修改 myArray[2].

实现接口

回顾 C#, Java 等语言, 接口的一个惯用法是显式要求一个类必须满足特定规范, 这同样适用于 TypeScript.

1
2
3
4
5
6
7
8
interface ClockInterface {
currentTime: Date;
}

class Clock implements ClockInterface {
currentTime: Date = new Date();
constructor(h: number, m: number) { }
}

你可以在接口中声明类必须实现的方法, 下例 setTime 展现该思想:

1
2
3
4
5
6
7
8
9
10
11
12
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}

class Clock implements ClockInterface {
currentTime: Date = new Date();
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}

接口只描述一个类公开的那部分.
你不能借助它确保一个实现类包含特定私有属性和方法.

静态面, 实例面

为了正确使用类和接口, 我们还需要理解每个类的静态面实例面.
下例是错的, 因为它试图以接口规定实现类构造器方法的形式.

1
2
3
4
5
6
7
8
interface ClockConstructor {
new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
currentTime: Date;
constructor(h: number, m: number) { }
}

原因是编译器在评估一个类对接口的实现程度时, 只检查类的实例面.
而构造器属于类的静态面, 不包括在内.

所以, 静态面应当单独考虑.
作为示例, 我们定义两个接口, ClockConstructor 用于构造器, 而 ClockInterface 用于实例方法.
然后, 为了方便, 我们定义工厂函数 createClock, 它实例化作为它参数的类型:

笔记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick(): void;
}

function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
return new ctor(hour, minute);
}

class DigitalClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("beep beep");
}
}
class AnalogClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("tick tock");
}
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

createClock 的第一个参数类型是 ClockConstructor, 调用 createClock(AnalogClock, 7, 32) 的参数 AnalogClock 具有符合接口要求的构造器方法.

另一种替代方法是类表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface ClockConstructor {
new (hour: number, minute: number);
}

interface ClockInterface {
tick();
}

const Clock: ClockConstructor = class Clock implements ClockInterface {
constructor(h: number, m: number) {}
tick() {
console.log("beep beep");
}
}

从接口继承

一个接口可以继承(译注: 原文为扩展, 这里选继承, 以免与扩展操作相混淆)另一个接口.
继承即从另一个接口拷贝成员, 我们经常将大接口拆分成可重用的多个部分, 继承增强了操作的灵活性.

1
2
3
4
5
6
7
8
9
10
11
interface Shape {
color: string;
}

interface Square extends Shape {
sideLength: number;
}

let square = {} as Square;
square.color = "blue";
square.sideLength = 10;

一个接口可以继承多个其他接口, 把所有成员集中到一起.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Shape {
color: string;
}

interface PenStroke {
penWidth: number;
}

interface Square extends Shape, PenStroke {
sideLength: number;
}

let square = {} as Square;
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

混合类型

我们知道, 接口能描述 JavaScript 丰富的类型表示法.
出于 JavaScript 动态, 灵活两大天性, 你偶尔会碰到融合上述多种工作方式的对象.

一种情况是一个对象既有属性, 又如同函数那样被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}

function getCounter(): Counter {
let counter = (function (start: number) { }) as Counter;
counter.interval = 123;
counter.reset = function () { };
return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

在和第三方 JavaScript 交互的过程中, 你可能要用到以上模式, 才能全面描述一个类型的形体.

从类继承

从类继承的接口继承所有类成员, 但不保留它们的实现.
如果形象化地把类比作建筑物, 从类继承的接口就是一份施工蓝图的副本.
上述”所有类成员”, 包括被继承类所有私有和保护成员.
包含私有或保护成员的接口适用范围窄于普通接口 — 只有被继承类的子类才能实现它.

较大继承层次结构将从该功能获益, 你可以限制某些代码只服务于具有特定属性集的子类.
这些子类除了继承自同一个基类, 不需要有别的关联.
举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Control {
private state: any;
}

interface SelectableControl extends Control {
select(): void;
}

class Button extends Control implements SelectableControl {
select() { }
}

class TextBox extends Control {
select() { }
}

// Error: Property 'state' is missing in type 'Image'.
class Image implements SelectableControl {
private state: any;
select() { }
}

class Location {

}

上例中, SelectableControl 包含 Control 所有成员, 包括 state.
state 是私有的, 只有 Control 的子类才有可以继承它.
这是因为只有 Control 子类才含有与 SelectableControl 接口同源的 state 私有属性, 这是体现私有属性相容的必要条件.

Control 类内部, 你可以访问一个 SelectableControl 实例的私有成员 state.
实际上, SelectableControl 实例用起来跟 Control 一样, 我们还知道它有一个 select 方法.
Button, TextBox 都是 SelectableControl 的子类 (因为它们都继承自 Control, 有 select 方法), 相反, ImageLocation 类都不是 SelectableControl 的子类.

如果这篇文章对您有用,可以考虑打赏:)