TypeScript 不仅能帮助前端改变思维方式,还能强化面向接口编程的思维和能力,而这正是得益于 Interface 接口类型。通过接口类型,我们可以清晰地定义模块内、跨模块、跨项目代码的通信规则。
TypeScript 对对象的类型检测遵循一种被称之为“鸭子类型”(duck typing)或者“结构化类型(structural subtyping)”的准则,即只要两个对象的结构一致,属性和方法的类型一致,则它们的类型就是一致的。
下面通过一段示例代码来初识一下接口类型,如下代码所示:
function Study(language: { name: string; age: () => number }) {
console.log(`这是一段创建于 ${language.age()} 年前的代码,名字叫 ${language.name}`);
}
Study({
name: 'TypeScript',
age: () => new Date().getFullYear() - 2012 // 这是一段创建于 10 年前的代码,名字叫 TypeScript
});
在上述代码中,我们定义了一个拥有 string 类型属性name、函数类型属性age的对象 language 作为参数的函数。同时,我们还使用类似定义 JavaScript 对象字面量的语法定义了一个内联接口类型来约束参数对象的类型。
然后,我们传递了一个 name 属性为 ‘TypeScript’ 的字符串、age 属性为计算年份差函数的对象字面量作为参数来调用这个函数。
在调用函数的过程中,TypeScript 静态类型检测到传递的对象字面量类型为 string 的 name 属性和类型为() => number 的 age 属性与函数参数定义的类型一致,于是不会抛出一个类型错误。
如果我们传入一个 name 属性是 number 类型或者缺少age属性的对象字面量,如下代码所示:
Study({
name: 2; // error TS2322: Type 'number' is not assignable to type 'string'.
});
Study({
name: 'TypeScript'; // error TS2345: Argument of type '{ name: string; }' is not assignable to parameter of type '{ name: string; age: () => number; }'.Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: () => number; }'.
});
这时,第 2 行会提示错误: ts(2322) number 不能赋值给 string,第 7 行也会提示错误: ts(2345) 实参与形参类型不兼容,缺少必需的属性 age。
同样,如果我们传入一个包含了形参类型定义里没有的属性的对象字面量作为实参,也会得到一个类型错误 ts(2345),实参与形参类型不兼容,如下代码所示:
Study({
id: 1, // error TS2345: Argument of type '{ id: number; name: string; age: () => number; }' is not assignable to parameter of type '{ name: string; age: () => number; }'.Object literal may only specify known properties, and 'id' does not exist in type '{ name: string; age: () => number; }'.
name: 'TypeScript',
age: () => new Date().getFullYear() - 2012
});
有意思的是,在上边的示例中,如果我们先把这个对象字面量赋值给一个变量,然后再把变量传递给函数进行调用,那么 TypeScript 静态类型检测就会仅仅检测形参类型中定义过的属性类型,而包容地忽略任何多余的属性,此时也不会抛出一个 ts(2345) 类型错误。如下图所示:
let ts = {
id: 2,
name: 'TypeScript',
age: () => new Date().getFullYear() - 2012
};
Study(ts); // ok
这并非一个疏忽或 bug,而是有意为之地将对象字面量和变量进行区别对待,我们把这种情况称之为对象字面量的 freshness。
因为这种内联形式的接口类型定义在语法层面与熟知的 JavaScript 解构颇为神似,所以很容易让我们产生混淆。下面我们通过如下示例对比一下解构语法与内联接口类型混用的效果。
// 纯JavaScript解构语法
function StudyJavaScript({name, age}) {
console.log(name, age);
}
// TypeScript 里解构与内联类型混用
function StudyTypeScript({name, age}: {name: string, age: () => number}) {
console.log(name, age);
}
/** 纯 JavaScript 解构语法,定义别名 */
function StudyJavaScript({name: aliasName}) { // 定义name的别名
console.log(aliasName);
}
/** TypeScript */
function StudyTypeScript(language: {name: string}) {
// console.log(name); // 不能直接打印name
console.log(language.name);
}
从上述代码中我们可以看到,在函数中,对象解构和定义接口类型的语法很类似,注意不要混淆。实际上,定义内联的接口类型是不可复用的,所以我们应该更多地使用 interface
关键字来抽离可复用的接口类型。
在 TypeScript 中,接口的语法和其他类型的语言并没有太大区别,我们通过如下所示代码一起看看接口是如何定义的:
interface test {
name: string;
age: () => number;
}
在上述代码中,我们定义了一个接口,它包含一个字符类型的属性 name 和一个函数类型的属性 age 。 从中我们发现接口的语法格式是在 interface 关键字的空格后+接口名字,然后属性与属性类型的定义用花括弧包裹。
在前边示例中,通过内联参数类型定义的 Study 函数就可以直接使用 test 接口来定义参数 language 的类型了。
function Study(language: ProgramLanguage) {
console.log(`这是一段创建于 ${language.age()} 年前的代码,名字叫 ${language.name}`);
}
我们还可以通过复用接口类型定义来约束其他逻辑。比如,我们通过如下所示代码定义了一个类型为 test 的变量 TypeScript。
let TypeScript: test;
接着,我们把满足接口类型约定的一个对象字面量赋值给了这个变量,如下代码所示,此时也不会提示类型错误。
TypeScript = {
name: 'TypeScript',
age: () => new Date().getFullYear() - 2012
}
而任何不符合约定的情况,都会提示类型错误。比如我们通过如下所示代码输入了一个空对象字面量,此时也会提示一个对象字面量类型 {} 缺少 name 和 age 属性的 ts(2739) 错误。
TypeScript = {}; // error TS2739: Type '{}' is missing the following properties from type 'test': name, age
按照如下所示代码添加 name 属性后,还是会提示一个对象字面量类型 { name: string; } 缺少必需的 age 属性的 ts( 2741) 错误。
TypeScript = {
name: 'TypeScript'
} // error TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'test'.
此外,如下代码所示,如果我们把一个 name 属性是 2、age 属性是 ‘Jae Wong’ 的对象赋值给 TypeScript ,会提示错误:ts(2322) number 类型不能赋值给 string与错误:ts(2322)string 不能赋值给函数类型。
TypeScript = {
name: 2, // error TS2322: Type 'number' is not assignable to type 'string'.
age: 'Jae Wong' // error TS2322: Type 'string' is not assignable to type '() => number'.
}
又或者如以下示例中额外多出了一个接口并未定义的属性 id,也会提示一个 ts(2322) 错误:对象字面量不能赋值给 test 类型的变量 TypeScript。
TypeScript = {
name: 'TypeScript',
age: () => new Date().getFullYear() - 2012,
id: 1 // error TS2322: Type '{ name: string; age: () => number; id: number; }' is not assignable to type 'test'.Object literal may only specify known properties, and 'id' does not exist in type 'test'.
}
在前边的例子中,如果我们希望缺少 age 属性的对象字面量也能符合约定且不抛出类型错误,确切地说在接口类型中 age 属性可缺省,那么我们可以在属性名之后通过添加如下所示的 ?
语法来标注可缺省的属性或方法。如以下示例中 OptionalTest 接口就拥有一个可缺省的函数类型的 age 属性。
interface test {
name: string;
age?: () => number;
}
let TypeScript: test;
TypeScript = {
name: 'TypeScript'
}
当属性被标注为可缺省后,它的类型就变成了显式指定的类型与 undefined 类型组成的联合类型,比如示例中 test 的 age 属性类型就变成了如下所示内容:
(() => number) | undefined;
思考一下,下面的 test2 与 test 等价吗:
interface test2 {
name: string;
age: (() => number) | undefined;
}
答案当然是不等价,可缺省意味着可以不设置属性键名,类型是 undefined 意味着属性键名不可缺省。
既然值可能是 undefined ,如果我们需要对该对象的属性或方法进行操作,就可以使用类型守卫或在属性名后加 ? ,如下代码所示:
if (typeof test.age === 'function') {
test.age();
}
test.age?.()
通过 typeof 条件判断,在确保了 age 属性是函数的情况下我们才会调用,这样就避免了运行时提示 age 不是函数的错误。
我们可能还会碰到这样的场景,希望对对象的某个属性或方法锁定写操作,比如前面例子中,定义了 TypeScript 变量之后,name 属性的值不能再被变更。这时,我们可以在属性名前通过添加 readonly 修饰符的语法来标注 name 为只读属性。
interface IReadOnlyTest {
readonly name: string;
readonly age: (() => number) | undefined;
}
let ReadOnlyTest: IReadOnlyTest = {
name: 'TypeScript',
age: undefined
}
ReadOnlyTest.name = 'Change' // error TS2540: Cannot assign to 'name' because it is a read-only property.
需要注意的是,这仅仅是静态类型检测层面的只读,实际上并不能阻止对对象的篡改。因为在转译为 JavaScript 之后,readonly 修饰符会被抹除。因此,任何时候与其直接修改一个对象,不如返回一个新的对象,这会是一种比较安全的实践。
接口类型还可以用来定义函数的类型 (仅仅是定义函数的类型,而不包含函数的实现),具体示例如下。
interface ITest {
name: string;
age?:() => number
}
interface IStudy {
(language: ITest): void
}
let StudyInterface: IStudy = language => console.log(`我正在学习${language.name}`);
StudyInterface({ name: 'TypeScript' }); // 我正在学习TypeScript
在示例中,我们定义了一个接口类型 IStudy,它有一个函数类型的匿名成员,函数参数类型 ITest,返回值的类型是 void,通过这样的格式定义的接口类型又被称之为可执行类型,也就是一个函数类型。
在之后的代码中,声明了一个 StudyInterface 类型的变量,并赋给它一个箭头函数作为值。回想一下上下文类型推断,赋值操作左侧的 IStudy 类型可以约束箭头函数的类型,所以即便我们没有显式指定函数参数 language 的类型,TypeScript 也能推断出它的类型就是 ITest。
实际上,我们很少使用接口类型来定义函数的类型,更多使用内联类型或类型别名配合箭头函数语法来定义函数类型,具体示例如下:
type StudyInterfaceType = (language: ITest) => void
我们给箭头函数类型指定了一个别名 StudyInterfaceType,在其他地方就可以直接复用 StudyInterfaceType,而不用重新声明新的箭头函数类型定义。
在实际工作中,使用接口类型较多的地方是对象,这些对象有一个共性,即所有的属性名、方法名都确定。
实际上,我们经常会把对象当 Map 映射使用,比如下边代码示例中定义了索引是任意数字的对象 LanguageRankMap 和索引是任意字符串的对象 LanguageMap。
let LanguageRankMap = {
1: 'TypeScript',
2: 'JavaScript'
};
let LanguageMap = {
TypeScript: 2012,
JavaScript: 1995
};
这个时候,我们需要使用索引签名来定义上边提到的对象映射结构,并通过 [索引名: 类型]
的格式约束索引的类型。
索引名称的类型分为 string 和 number 两种,通过如下定义的 LanguageRankInterface 和 LanguageYearInterface 两个接口,我们可以用来描述索引是任意数字或任意字符串的对象。
interface LanguageRankInterface {
[rank: number]: string;
}
interface LanguageYearInterface {
[name: string]: number;
}
let LanguageRankMap: LanguageRankInterface = {
1: 'TypeScript',
2: 'JavaScript',
'JaeWong': 3 // error TS2322: Type '{ 1: string; 2: string; JaeWong: number; }' is not assignable to type 'LanguageRankInterface'.Object literal may only specify known properties, and ''JaeWong'' does not exist in type 'LanguageRankInterface'.
}
let LanguageMap: LanguageYearInterface = {
TypeScript: 2012,
JavaScript: 1995,
2023: 2021
}
注意:在上述示例中,数字作为对象索引时,它的类型既可以与数字兼容,也可以与字符串兼容,这与 JavaScript 的行为一致。因此,使用 0 或 ‘0’ 索引对象时,这两者等价。
同样,我们可以使用 readonly 注解索引签名,此时将对应属性设置为只读就行,如下代码所示:
interface LanguageRankInterface {
readonly [rank: number]: string;
}
interface LanguageYearInterface {
readonly [name: string]: number;
}
在上述示例中,LanguageRankInterface 和 LanguageYearInterface 任意的数字或者字符串类型的属性都是只读的。
注意:虽然属性可以与索引签名进行混用,但是属性的类型必须是对应的数字索引或字符串索引的类型的子集,否则会出现错误提示。
比如下面的例子:
interface StringMap {
[prop: string]: number;
age: number;
name: string; // ts(2411) name 属性的 string 类型不能赋值给字符串索引类型 number
}
interface NumberMap {
[rank: number]: string;
1: string;
0: number; // ts(2412) 0 属性的 number 类型不能赋值给数字索引类型 string
}
在上述示例中,因为接口 StringMap 属性 name 的类型 string 不是它所对应的字符串索引(prop: string)类型 number 的子集,所以会提示一个错误。同理,因为接口 NumberMap 属性 0 的类型 number 不是它所对应的数字索引(rank: number)类型 string 的子集,所以也会提示一个错误。
另外,由于上边提到了数字类型索引的特殊性,所以我们不能约束数字索引属性与字符串索引属性拥有截然不同的类型,具体示例如下:
interface LanguageRankInterface {
[rank: number]: string; // // ts(2413) 数字索引类型 string 类型不能赋值给字符串索引类型 number
[prop: string]: number;
}
这里我们定义了 LanguageRankInterface 的数字索引 rank 的类型是 string,与定义的字符串索引 prop 的类型 number 不兼容,所以会提示一个 ts(2413) 错误。
在 TypeScript 中,接口类型可以继承和被继承,比如我们可以使用如下所示的 extends 关键字实现接口的继承。
interface ITest {
name: string;
age?:() => number;
}
interface DynamicTest extends ITest {
rank: number // 定义新属性
}
interface TypeTest extends ITest {
typeChecker: string; // 定义新属性
}
// 继承多个
interface TypeScript extends DynamicTest, TypeTest {
name: 'TypeScript';
}
在上述示例中,先是定义了两个继承了 ITest 的接口 DynamicTest 和 TypeTest,它们会继承 ITest 所有的属性定义。而后定义了同时继承了 DynamicTest 和 TypeTest 的接口 TypeScript,它会继承 DynamicTest 和 TypeTest 所有的属性定义,并且使用同名的 name 属性定义覆盖了继承过来的 name 属性定义。
注意:我们仅能使用兼容的类型覆盖继承的属性,如下代码所示:
interface WrongType extends ITest {
name: number;
}
在上述代码中,因为 ITest 的 name 属性是 string 类型,WrongType 的 name 属性是 number,二者不兼容,所以不能继承,也会提示一个 ts(6196) 错误。
我们既可以使用接口类型来约束类,反过来也可以使用类实现接口,那两者之间的关系到底是什么呢?这里,我们通过使用如下所示的 implements 关键字描述一下类和接口之间的关系。
interface ITest {
name: string;
age:() => number;
}
class TestClass implements ITest {
name: string = '';
age = () => new Date().getFullYear() - 2012
}
在上述代码中,类 TestClass 实现了 ITest 接口约定的 name、age 等属性和方法,如果我们移除 name 或者 age 的实现,将会提示一个类型错误。
接口类型的一个作用是将内联类型抽离出来,从而实现类型可复用。其实,我们也可以使用类型别名接收抽离出来的内联类型实现复用。
此时,我们可以通过如下所示 type 别名名字 = 类型定义
的格式来定义类型别名:
type TestType = {
name: string;
age:() => number;
}
在上述代码中,乍看上去有点像是在定义变量,只不过这里我们把 let 、const 、var 关键字换成了 type 罢了。
此外,针对接口类型无法覆盖的场景,比如组合类型、交叉类型,我们只能使用类型别名来接收,如下代码所示:
interface ITest {
name: string;
age:() => number;
}
// 联合
type MixedType = string | number;
// 交叉
type IntersectionType = { id: number; name: string; } & { age: number; name: string; };
// 提取接口属性类型
type AgeType = ITest['age'];
在上述代码中,我们定义了一个 IntersectionType 类型别名,表示两个匿名接口类型交叉出的类型;同时定义了一个 AgeType 类型别名,表示抽取的 ITest age 属性的类型。
注意:类型别名,诚如其名,即我们仅仅是给类型取了一个新的名字,并不是创建了一个新的类型。
通过以上介绍,我们已经知道适用接口类型标注的地方大都可以使用类型别名进行替代,这是否意味着在相应的场景中这两者等价呢?
实际上,在大多数的情况下使用接口类型和类型别名的效果等价,但是在某些特定的场景下这两者还是存在很大区别。比如,重复定义的接口类型,它的属性会叠加,这个特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展,如下代码所示:
interface ITest {
id: number;
}
interface ITest {
name: string;
}
let test: ITest = {
id: 1,
name: 'test'
}
在上述代码中,先后定义的两个 ITest 接口属性被叠加在了一起,此时我们可以赋值给 test 变量一个同时包含 id 和 name 属性的对象。
不过,如果我们重复定义类型别名,如下代码所示,则会提示一个 ts(2300) 错误:
// ts(2300) 重复的标志
type ITest {
id: number;
}
// ts(2300) 重复的标志
type ITest {
name: string;
}
let test: ITest = {
id: 1,
name: 'test'
}