- 文档
- 教程
【免费下载链接】TypeScript
TypeScript 使用手册(中文版)翻译。http://www.typescriptlang.org
本文是 TypeScript 使用手册(中文版)「手册」章节的接口专题(对应文档 zh/handbook/interfaces.md),系统讲解 TypeScript 接口的核心原则——结构性子类型化(结构化类型/鸭式辨型),覆盖接口定义、可选属性、只读属性、额外属性检查、函数类型、可索引类型、类类型、接口继承、混合类型与接口继承类等全部要点。读完本文,你将掌握用接口为代码与第三方代码"定义契约"的完整方法论,并能理解 TypeScript 类型检查器在接口兼容性上的底层判定逻辑,直接支撑你在实际项目中编写健壮、可读、可复用的类型定义。
介绍:接口与结构性子类型化
TypeScript 的核心原则之一是对值所具有的结构进行类型检查。它有时被称做"鸭式辨型法"或"结构性子类型化"。在 TypeScript 里,接口的作用就是为这些类型命名,并为你的代码或第三方代码定义契约。
这种设计与其他语言(如 C#、Java)的"名义类型系统"有着根本差异:在基于名义类型的语言中,类型兼容性依赖明确的声明与类型名称;而 TypeScript 的类型系统只关注值的外形(shape)。这一点在 类型兼容性 一章中有更详细的展开——例如一个没有显式声明实现Named接口的Person类,只要其结构(成员)与接口匹配,就可以赋给Named类型的变量,这在名义类型语言中会直接报错。TypeScript 之所以采用结构子类型,正是为了贴合 JavaScript 广泛使用匿名对象(函数表达式、对象字面量)的典型写法。
接口初探
下面通过一个简单示例来观察接口是如何工作的:
function printLabel(labeledObj: { label: string }) { console.log(labeledObj.label); } let myObj = { size: 10, label: 'Size 10 Object' }; printLabel(myObj);类型检查器会查看printLabel的调用:它有一个参数,并要求这个对象参数有一个名为label、类型为string的属性。需要注意,我们传入的对象参数实际上会包含很多属性(这里多了size),但编译器只会检查那些必需的属性是否存在、类型是否匹配。也就是说,只要传入的对象满足必要条件,它就是被允许的。不过,有些时候 TypeScript 并不会这么宽松(比如对象字面量的额外属性检查),下面会稍做讲解。
下面重写上面的例子,这次使用接口来描述"必须包含一个label属性且类型为string":
interface LabeledValue { label: string; } function printLabel(labeledObj: LabeledValue) { console.log(labeledObj.label); } let myObj = { size: 10, label: 'Size 10 Object' }; printLabel(myObj);LabeledValue接口就好比一个名字,用来描述上面例子里的要求。它代表了"有一个label属性且类型为string的对象"。需要注意的是,我们在这里并不能像在其它语言里一样,说传给printLabel的对象"实现了"这个接口——我们只会去关注值的外形。
还有一点值得提的是:类型检查器不会去检查属性的顺序,只要相应的属性存在并且类型正确就可以。从接口语法上看,interface声明只创建"类型"实体(不创建运行时值),这在 声明合并 的声明实体表中可以印证——接口属于"仅创建类型"的声明,编译后不会产生任何 JavaScript 输出,因此接口的使用成本为零。
可选属性
接口里的属性不全都是必需的。有些是只在某些条件下存在,或者根本不存在。可选属性在应用"option bags"模式时很常用,即给函数传入的参数对象中只有部分属性被赋值。
下面是应用了 "option bags" 的例子:
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' });带有可选属性的接口与普通的接口定义差不多,只是在可选属性名字定义的后面加一个?符号(color?: string表示该属性可以缺失)。
可选属性的好处有二:
- 对可能存在的属性进行预定义:调用方可以只传入部分配置,接口依然完整描述了"可能包含哪些键";
- 捕获引用了不存在的属性时的错误:比如,我们故意将
createSquare里的color属性名拼错,就会得到一个错误提示:
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' });编译器会精确地指出config.clor不存在于SquareConfig类型上,这种"打错一个字母立刻被揪出来"的能力,正是接口对配置对象类代码的价值所在。
只读属性
一些对象属性只能在对象刚刚创建的时候修改其值。你可以在属性名前用readonly来指定只读属性:
interface Point { readonly x: number; readonly y: number; }你可以通过赋值一个对象字面量来构造一个Point。赋值后,x和y再也不能被改变了:
let p1: Point = { x: 10, y: 20 }; p1.x = 5; // error!TypeScript 还具有ReadonlyArray<T>类型,它与Array<T>相似,只是把所有可变方法(如push、pop、splice等)去掉了,因此可以确保数组创建后再也不能被修改:
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赋值到一个普通数组也是不可以的——只读性在赋值方向上也得到了保护。但你可以用类型断言重写这个限制:
a = ro as number[];readonlyvsconst
最简单判断该用readonly还是const的方法是看把它做为变量使用还是做为属性使用:做为变量使用的话用const,若做为属性则使用readonly。两者的作用域不同:const约束变量引用的重新绑定,readonly约束属性值的再次赋值。
额外的属性检查
我们在第一个例子里使用了接口,TypeScript 让我们传入{ size: number; label: string; }到仅期望得到{ label: string; }的函数里。我们已经学过了可选属性,并且知道它们在 "option bags" 模式里很有用。
然而,天真地将这两者结合的话就会像在 JavaScript 里那样搬起石头砸自己的脚。比如,拿createSquare例子来说:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): { color: string; area: number } { // ... } let mySquare = createSquare({ colour: 'red', width: 100 });注意传入createSquare的参数拼写为colour而不是color。在 JavaScript 里,这会默默地失败——config.colour永远是undefined,拼写错误被静默吞掉。
你可能会争辩这个程序已经正确地类型化了:width属性是兼容的,不存在color属性,而且额外的colour属性是无意义的。然而,TypeScript 会认为这段代码可能存在 bug。对象字面量会被特殊对待,而且会经过额外属性检查:当将它们赋值给变量或作为参数传递的时候,如果一个对象字面量存在任何"目标类型"不包含的属性,你就会得到一个错误:
// 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 });注意编译器甚至会给出 "Did you mean to write 'color'?" 这样的纠错提示。这正是接口作为契约的"严苛"一面:契约内允许可选,契约外不允许凭空多出键。
绕开这些检查非常简单。最简便的方法是使用类型断言:
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);然而,最佳的方式是添加一个字符串索引签名,前提是你能够确定这个对象可能具有某些做为特殊用途使用的额外属性。如果SquareConfig带有上面定义的类型的color和width属性,并且还会带有任意数量的其它属性,那么我们可以这样定义它:
interface SquareConfig { color?: string; width?: number; [propName: string]: any; }我们稍后会讲到索引签名,但在这我们要表示的是:SquareConfig可以有任意数量的属性,并且只要它们不是color和width,那么就无所谓它们的类型是什么。
还有最后一种跳过这些检查的方式,这可能会让你感到惊讶,它就是将这个对象赋值给另一个变量:因为squareOptions不会经过额外属性检查(额外属性检查只针对对象字面量),所以编译器不会报错:
let squareOptions = { colour: 'red', width: 100 }; let mySquare = createSquare(squareOptions);但上面的方法只在squareOptions和SquareConfig之间有共同的属性时才好用——在这个例子中,这个属性为width。如果变量间不存在共同的对象属性将会报错,例如:
let squareOptions = { colour: 'red' }; let mySquare = createSquare(squareOptions);这里squareOptions与目标类型没有任何公共成员,结构上完全无法兼容,因此即使绕过对象字面量检查也会被常规结构类型检查拦截。要留意:在像上面一样的简单代码里,你可能不应该去绕开这些检查。对于包含方法和内部状态的复杂对象字面量来讲,你可能需要使用这些技巧,但大部分额外属性检查错误是真正的 bug。就是说,如果你遇到了额外类型检查出的错误(比如 "option bags" 场景),你应该去审查一下你的类型声明。在这里,如果确实支持传入color或colour属性到createSquare,你应该修改SquareConfig定义来体现出这一点,而不是绕过检查。
函数类型
接口能够描述 JavaScript 中对象拥有的各种各样的外形。除了描述带有属性的普通对象外,接口也可以描述函数类型。
为了使用接口表示函数类型,我们需要给接口定义一个调用签名(call signature)。它就像是一个只有参数列表和返回值类型的函数定义。参数列表里的每个参数都需要名字和类型:
interface SearchFunc { (source: string, subString: string): boolean; }这样定义后,我们可以像使用其它接口一样使用这个函数类型的接口。下例展示了如何创建一个函数类型的变量,并将一个同类型的函数赋值给这个变量:
let mySearch: SearchFunc; mySearch = function (source: string, subString: string) { let result = source.search(subString); return result > -1; };对于函数类型的类型检查来说,函数的参数名不需要与接口里定义的名字相匹配。比如,我们使用下面的代码重写上面的例子:
let mySearch: SearchFunc; mySearch = function (src: string, sub: string): boolean { let result = src.search(sub); return result > -1; };函数的参数会逐个进行检查,要求对应位置上的参数类型是兼容的。如果你不想指定类型,TypeScript 的类型系统会推断出参数类型,因为函数直接赋值给了SearchFunc类型变量;函数的返回值类型也是通过其返回值推断出来的(此例是false和true):
let mySearch: SearchFunc; mySearch = function (src, sub) { let result = src.search(sub); return result > -1; };如果让这个函数返回数字或字符串,类型检查器会警告我们函数的返回值类型与SearchFunc接口中的定义不匹配:
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'; };函数赋值时的参数兼容性(参数双向协变/逆变、返回值协变等细节)在 类型兼容性 的"比较两个函数"一节有系统性说明。函数类型的接口非常适合描述回调、处理器等"可调用对象",是设计库 API 时的常用手段。
可索引的类型
与使用接口描述函数类型差不多,我们也可以描述那些能够"通过索引得到"的类型,比如a[10]或ageMap["daniel"]。可索引类型具有一个索引签名,它描述了对象索引的类型,还有相应的索引返回值类型。看一个例子:
interface StringArray { [index: number]: string; } let myArray: StringArray; myArray = ['Bob', 'Fred']; let myStr: string = myArray[0];上面例子里,我们定义了StringArray接口,它具有索引签名。这个索引签名表示了:当用number去索引StringArray时会得到string类型的返回值。
TypeScript 支持两种索引签名:字符串和数字。可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。这是因为当使用number来索引时,JavaScript 会将它转换成string然后再去索引对象——也就是说用100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。下面的错误示例展示了违反该规则的情形:
class Animal { name: string; } class Dog extends Animal { breed: string; } // 错误:使用数值型的字符串索引,有时会得到完全不同的 Animal! interface NotOkay { [x: number]: Animal; [x: string]: Dog; }如果数字索引返回Animal而字符串索引返回Dog,那么myArray[100](被转换成"100"索引)可能得到Animal而非Dog,类型就不一致了。
字符串索引签名能够很好地描述dictionary(字典)模式,并且它们也会确保所有属性与其返回值类型相匹配。因为字符串索引声明了obj.property和obj["property"]两种形式都可以。下面的例子里,name的类型与字符串索引类型不匹配,所以类型检查器给出一个错误提示:
interface NumberDictionary { [index: string]: number; length: number; // 可以,length 是 number 类型 name: string; // 错误,`name` 的类型与索引类型返回值的类型不匹配 }但如果索引签名是包含属性类型的联合类型,那么使用不同类型的属性就是允许的:
interface NumberOrStringDictionary { [index: string]: number | string; length: number; // ok, length is a number name: string; // ok, name is a string }最后,你可以将索引签名设置为只读,这样就防止了给索引赋值:
interface ReadonlyStringArray { readonly [index: number]: string; } let myArray: ReadonlyStringArray = ['Alice', 'Bob']; myArray[2] = 'Mallory'; // error!你不能设置myArray[2],因为索引签名是只读的。索引签名在现代 TypeScript 的进阶用法中也是基础——例如 keyof 类型运算符 中指出,当类型具有string或number索引签名时,keyof会返回相应的string | number;而 索引访问类型 则展示了如何基于接口成员类型做进一步推导(如Person["age"])。
类类型
实现接口
与 C# 或 Java 里接口的基本作用一样,TypeScript 也能够用它来明确地强制一个类去符合某种契约:
interface ClockInterface { currentTime: Date; } class Clock implements ClockInterface { currentTime: Date = new Date(); constructor(h: number, m: number) {} }你也可以在接口中描述一个方法,在类里实现它,如同下面的setTime方法一样:
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) {} }接口描述了类的公共部分,而不是公共和私有两部分。它不会帮你检查类是否具有某些私有成员。关于类的继承、访问修饰符(public/private/protected)、抽象成员等更完整的类体系知识,可参见 类 一章。
类静态部分与实例部分的区别
当你操作类和接口的时候,你要知道类是具有两个类型的:静态部分的类型和实例的类型。你会注意到,当你用构造器签名去定义一个接口并试图定义一个类去实现这个接口时会得到一个错误:
interface ClockConstructor { new (hour: number, minute: number); } class Clock implements ClockConstructor { currentTime: Date; constructor(h: number, m: number) {} }这里因为当一个类实现了一个接口时,只对其实例部分进行类型检查。constructor存在于类的静态部分,所以不在检查的范围内。
因此,我们应该直接操作类的静态部分。看下面的例子,我们定义了两个接口——ClockConstructor为构造函数所用、ClockInterface为实例方法所用。为了方便,我们定义一个构造函数createClock,它用传入的类型创建实例:
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是否符合构造函数签名。这种"构造器签名 + 实例接口"分离的模式,是 TypeScript 中实现工厂函数(factory)类型安全的标准做法。
另一种简单方式是使用类表达式:
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'); } };继承接口
和类一样,接口也可以相互继承。这让我们能够从一个接口里复制成员到另一个接口里,可以更灵活地将接口分割到可重用的模块里:
interface Shape { color: string; } interface Square extends Shape { sideLength: number; } let square = {} as Square; square.color = 'blue'; square.sideLength = 10;一个接口可以继承多个接口,创建出多个接口的合成接口:
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;接口的多继承语义上接近 高级类型 中介绍的交叉类型(Shape & PenStroke):继承后的Square同时拥有所有父接口的成员,形成"合成契约"。
混合类型
先前我们提过,接口能够描述 JavaScript 里丰富的类型。因为 JavaScript 动态灵活的特点,有时你会希望一个对象可以同时具有上面提到的多种类型。
一个例子就是,一个对象可以同时作为函数和对象使用,并带有额外的属性:
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;Counter接口同时声明了调用签名((start: number): string)、属性(interval: number)与方法(reset(): void)。在使用 JavaScript 第三方库的时候,你可能需要像上面那样去完整地定义类型——例如许多 JS 库导出的"可调用对象"(既可直接调用又挂载了属性)都需要用混合类型接口来精确描述。
接口继承类
当接口继承了一个类类型时,它会继承类的成员但不包括其实现。就好像接口声明了所有类中存在的成员,但并没有提供具体实现一样。接口同样会继承到类的private和protected成员。这意味着:当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)。
当你有一个庞大的继承结构时这很有用,但要指出的是你的代码只在子类拥有特定属性时起作用。除了继承自基类,子类之间不必相关联。例:
class Control { private state: any; } interface SelectableControl extends Control { select(): void; } class Button extends Control implements SelectableControl { select() {} } class TextBox extends Control { select() {} } class ImageControl implements SelectableControl { // Error: Class 'ImageControl' incorrectly implements interface 'SelectableControl'. // Types have separate declarations of a private property 'state'. private state: any; select() {} }在上面的例子里,SelectableControl包含了Control的所有成员,包括私有成员state。因为state是私有成员,所以只能够是Control的子类们才能实现SelectableControl接口——因为只有Control的子类才能够拥有一个声明于Control的私有成员state,这对私有成员的兼容性是必需的。
在Control类内部,是允许通过SelectableControl的实例来访问私有成员state的。实际上,SelectableControl就像Control一样,并拥有一个select方法。Button和TextBox类可以视为SelectableControl的实现者(因为它们都继承自Control并有select方法)。而对于ImageControl类,它有自身的私有成员state而不是通过继承Control得来的,所以它不可以实现SelectableControl——编译器会以 "Types have separate declarations of a private property 'state'" 明确指出二者各自的state声明互不相通。这一机制常被用来在大型继承体系中"限定"某类接口只能由特定基类家族实现。
小结:接口在 TypeScript 类型体系中的位置
接口是 TypeScript 类型系统的地基性构件,它贯穿于整个手册体系:
- 与 基础类型(
string、number、数组等)相比,接口让开发者能够为复合结构命名并复用; - 与 类 配合,接口可以描述类的公共契约(
implements),并借助构造器签名与实例接口的分离实现工厂模式; - 与 函数、泛型 组合,接口可用于描述回调签名与泛型约束;
- 接口在 声明合并 中是最常见也最灵活的合并对象(同名接口的成员会被自动合并),且
interface只产生类型实体、不产生运行时输出; - 在 类型兼容性 的结构子类型规则下,接口是判定"两个值外形是否一致"的基准;
- 在 高级类型 与 keyof 类型运算符、索引访问类型 中,接口定义的成员与索引签名又是类型推导的重要输入。
掌握本文介绍的接口定义、可选与只读属性、额外属性检查的绕过与规避、函数类型与可索引类型、类类型与接口继承,你就拥有了用 TypeScript 为"任意 JavaScript 外形"建立精确契约的完整工具集,能够在代码与第三方库之间写出既安全又灵活的类型边界。
- 文档
- 教程
【免费下载链接】TypeScript
TypeScript 使用手册(中文版)翻译。http://www.typescriptlang.org
相关推荐
TypeScript高级类型应用:Upterm接口设计与类型安全
TypeScript高级类型应用:Upterm接口设计与类型安全 在21世纪的终端模拟器开发中,类型安全是保障复杂交互逻辑可靠性的关键。Upterm作为现代化终
开发工具桌面应用TypeScript 匿名元组类型(Anonymous Tuple Type)完全指南:用固定结构数组表达精确的类型契约
TypeScript 匿名元组类型(Anonymous Tuple Type)完全指南:用固定结构数组表达精确的类型契约 导读 元组(Tuple)是 TypeS
文档教程Cycle.js与TypeScript高级类型:构建类型安全的响应式应用
Cycle.js与TypeScript高级类型:构建类型安全的响应式应用 你是否在开发响应式应用时遇到过类型不匹配导致的运行时错误?是否希望在编译阶段就能捕获数
前端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考