用模板字面类型驯服字符串的详细指南

90 阅读5分钟

在ES2015给JavaScript语言带来的大量改进中,有一个强大的功能,叫做 "模板字面"。当然,TypeScript作为JavaScript的超集,从一开始就支持这种有用的结构。然而,TypeScript 4.1引入了模板字面概念的新应用,以增加其类型系统的力量和实用性:它引入了一个叫做模板字面类型的东西。在这篇文章中,我们将讨论这个新功能,以及如何使用它来增加类型系统可能面临的最具挑战性的东西之一的健壮性:字符串。

回顾模板字样

在我们进入新内容之前,让我们花点时间回顾一下JavaScript/TypeScript中的模板字面是什么。在ES2015之前,创建数据驱动的字符串需要大量使用字符串连接法(即把类似字符串的值加在一起,形成一个更大的字符串)。这通常看起来像这样:

type User = {firstName: string, lastName: string};
 
const user = {
    firstName: 'Arthur',
    lastName: 'Dent'
};
 
// use plus sign for string concatenation
let fullName = user.lastName + ', ' + user.firstName;
 
// use concat method
fullName = user.firstName.concat(', ', user.lastName);

虽然这两种技术都有效,但它们包含了很多噪音,让人难以理解代码的意图。模板字样通过允许使用占位符将数据直接注入到字符串中来消除这种杂乱,如下图所示:

type User = {firstName: string, lastName: string};
 
const user = {
    firstName: 'Arthur',
    lastName: 'Dent'
};
 
let fullName = `${user.firstName}, ${user.lastName}`;

这个例子更容易阅读和理解。模板字面使用反斜线,即重音,来给模板划界。在这些定界符中,我们可以将字符串字面意义与数据值结合起来,这些数据值被包在大括号中,前面有一个美元符号--${data value} 。模板字面是JavaScript和TypeScript中强有力的工具,其功能远远超出了这里所描述的,但这足以让我们回顾一下我们在这里的原因--模板字面的类型。如果你想了解更多关于模板字面的信息,MDN有一个很好的页面可以让你开始。

TypeScript中的模板字面类型

基础知识

正如我们在上面看到的,模板字面是用来使用字符串字面和数据的组合来生成字符串的。另一方面,模板字面类型被用来将现有的类似字符串的类型组合在一起,以形成一个新的类型。例如,假设我们有一个非常简单的字符串字面符号类型:

type Restaurant = 'Milliways';

模板字面类型可以使用模板字面语法建立一个新的类型,像这样用类型值替换习惯的数据值:

type Greeting = `Hello, welcome to ${Restaurant}!`;

在这个例子中,Greeting是一个只能匹配一个值的类型--字符串 "你好,欢迎来到Milliways!"。这不是很有用,但我们可以在这个基本前提下做一些非常巧妙的事情。

组成字符串字面符号

让我们用两个新的字符串字面类型来替换之前的例子中的字符串字面类型,然后用这些字面类型来创建一个模板字面类型:

type Place = 'Milliways' | 'Betelgeuse' | 'Magrathea';
type CriticalItem = 'Towel' | 'Electronic Thumb' | 'Babel Fish';
 
type Message = `As long as you know where your ${CriticalItem} is, you can get to ${Place}`;

TypeScript的编译器将确保用Message类型创建的变量只使用我们定义的地方和关键项。这就把有效信息的数量限制在了9个--三个地方中的一个与三个关键项中的一个相结合。虽然这个例子是微不足道的,但这种能力在许多可以组合多个字符串的地方都很方便。考虑一下CSS的边框属性。 在其最一般的形式中,它接受宽度、样式和颜色。根据我们已经讨论过的内容,我们可以处理其中的两个--样式和颜色:

type Style = 'none' | 'dotted' | 'dashed' | 'solid'; // truncated for brevity
type Color = `red` | 'green' | 'blue'; // truncated for brevity
 
type BorderStyle = `${Style} ${Color}`;
 
let borderStyle: BorderStyle = 'solid red';

当然,在CSS中定义颜色属性的方法比我们在这里掌握的要多,但希望这能说明问题。我们可以将字符串字头组合在一起,创建强类型的模板来控制一个字符串的值。然而,我们确实缺少一个属性。目前,我们的BorderStyle类型不允许我们指定一个宽度。我们想把宽度定义为一定的像素数,但我们有一个问题:有效的宽度是无限的。我们需要一种方法来告诉模板,它应该接受一个数字而不需要定义所有的有效值。幸运的是,模板字面类型已经解决了这个问题。

使用类字符串类型和模板字面类型的泛型

到目前为止,我们所有的例子都使用字符串字面类型或其他模板字面类型来定义模板字面类型。TypeScript还允许我们简单地指定将被使用的数据类型。考虑到这一点,我们可以完成我们的例子:

type Style = 'none' | 'dotted' | 'dashed' | 'solid'; // truncated for brevity
type Color = `red` | 'green' | 'blue'; // truncated for brevity
 
type BorderStyle = `${number}px ${Style} ${Color}`;
 
let borderStyle: BorderStyle = '3px solid red';

在我们的例子中,边框宽度不受限制,不需要有一个特定的值。只要提供了一个有效的数字,一切都可以进行。当与泛型结合时,约束占位符类型的能力释放了更多的可能性。

TypeScript文档介绍了一个很好的例子,说明模板字面类型如何与类型匹配和泛型相结合来编排设计意图,所以我们将以他们的例子为基础。在许多TypeScript API中,一个常见的任务是提供观察对象属性的能力,当它们发生变化时,触发一些行动。我们将使用一个假设的函数,makeWatchedObject ,它接受一个对象字面,并返回一个带有on 方法的对象,该方法可用于注册回调函数,在给定的属性变化时被调用:

type PropEventSource = {
    on(eventName: string, callback: (newValue: any) => void): void;
}
 
declare function makeWatchedObject(obj: any): PropEventSource;
 
const product = makeWatchedObject({
    name: 'Babel Fish',
    quantity: 42
});
 
product.on('quantityChanged', (newValue) => {});
 
product.on('quantity', (newValue) => {}); // doesn't follow convention
 
product.on('nmaeChanged', (newValue) => {}); // typo in property name

这个例子执行了我们想要的任务,但它并不是非常类型安全的。on方法的第一个参数是由一个现有的属性和添加后缀 "Changed "形成的,如上所示。然而,在这个例子中没有任何东西可以确保这个约定得到遵守,也没有引入任何类型错误。使用带有类型约束的模板字面类型,我们可以解决这个问题:

type PropEventSource<Type> = {
    on<Key extends string & keyof Type>
        (eventName: `${Key}Changed`, callback: (newValue: any) => void): void;
}
 
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;
 
const product = makeWatchedObject({
    name: 'Babel Fish',
    quantity: 42
});
 
product.on('quantityChanged', (newValue) => {});
 
product.on('quantity', (newValue) => {}); // doesn't follow convention
 
product.on('nmaeChanged', (newValue) => {}); // typo in property name

在这个例子中,PropEventSource 已经被转化为一个通用类型。它所包含的on 方法也是泛型的,并利用联合类型来确保Key 既是一个字符串,又是被做成可观察的Type 的键。强类型的Key ,然后被用于模板字面类型,以约束允许的eventNames ,使其遵循我们的API所期望的惯例,消除猜测和对单独文档的需求。

事实上,我们还可以进一步改进。PropEventSource 确切地知道我们要观察的是哪个属性。我们可以用它来推导出传入回调函数的newValue 参数的类型,就像这样:

type PropEventSource<Type> = {
    on<Key extends string & keyof Type>
        (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void): void;
}
 
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;
 
const product = makeWatchedObject({
    name: 'Babel Fish',
    quantity: 42
});
 
product.on('quantityChanged', (newValue: number) => {});
 
product.on('nameChanged', (newValue: string) => {});

利用来自通用参数的类型信息,我们可以对回调进行强类型化。这与模板字面类型使我们能够在事件名称上放置的类型约束相结合,产生了一个强大的、自我记录的代码块,它简洁而易于理解。

TypeScript 4.3中对模板字面类型的改进

虽然模板字面类型非常强大,但它们有一些改进的空间。在 TS 4.3 中,其中一些差距已经被弥补。第一个改进涉及TypeScript如何处理两种不同类型的占位符:类字符串类型和类型模式。最初发布时,TypeScript无法理解模板字符串何时与字面类型匹配。考虑一下TypeScript文档中的这个例子:

function bar(s: string): `hello ${string}` {
    // Previously an error, now works!
    return `hello ${s}`;
}

函数bar接收一个字符串并返回一个有类型约束的模板字面。TypeScript现在可以理解,返回的字符串与所需的返回类型相匹配。以前,这将产生错误信息:

Type 'string' is not assignable to type '`hello ${string}`'

另一个主要的变化是TypeScript如何处理具有独立但等价的模板字面类型的变量的赋值:

declare let s1: `${number}-${number}-${number}`;
declare let s2: `1-2-3`;
declare let s3: `${number}-2-3`;
s1 = s2;
s1 = s3;

第一个赋值,s1 = s2 ,一直在工作,因为TypeScript能够将s2模式匹配到s1。TypeScript 4.3现在可以检查两个不同模板字面类型之间的占位符,允许第二个赋值也被使用。

总结

TypeScript的核心任务一直是为JavaScript提供类型安全。带来这种安全的最具挑战性的领域之一是在处理字符串时。在语言中引入模板字面类型,为开发者提供了另一个工具,以提高他们代码的健壮性和清晰度。