通常,在做TypeScript讲座时,我只是打开一个代码编辑器,然后砍掉一些在某种情况下有帮助的酷类型。这一次,我被要求做同样的事情,但要在20分钟的时间限制内完成。这已经超级艰难了,所以我把整个事情编成脚本,并求助于有一定进度的幻灯片。我搞砸的机会更少了!这让我不仅能给你们提供幻灯片,还能给你们写下这次演讲的内容。我将给自己一点自由,并在适当的地方充实它。请欣赏!
文字记录#
最近我发现了一个不错的小库,叫做commander。它可以帮助你创建Node.js CLI,解析你的参数并为你提供一个带有你设置的所有标志的对象。正如它的作者所期望的那样,它的API是光荣的。
该API看起来像这样。
const program = new Commander();
const opts = program
.option("--episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();
if (!opts.keep) {
// Remove all files
}
我喜欢的是,你写你的应用程序就像你写你的手册页或帮助对话框一样。你写它就像你会读它一样。这是非常好的,也是我在许多其他编程语言中所怀念的JavaScript的优点之一。你可以灵活地使用字符串。
在这个例子中,我们处理了三种可能性。
- 强制性参数,我们需要传递一个字符串值
- 标志,要么是
true,要么是false - 可选参数,要么不设置(
false),要么设置(true),要么用一个字符串值设置。
另外,还有一个很好的流畅的接口。一个构建者模式。这就是让API变得漂亮的东西。
但有一件事让我很不爽,那就是我总是需要参考我设置的选项,以了解哪些标志是可用的,它们意味着什么。这也是笨拙的我经常碰见错误和错字的地方。你知道如果我把我的选项称为--keeps ,但要求不叫keep ,会发生什么?是的,因为keep 将是undefined ,我们总是执行删除文件的部分。
或者,如果我把ratio 改为一个强制性的参数,而不是一个可选参数呢?突然间,所有我假设ratio 是一个布尔值的检查都是错误的。
这里有很多潜在的类型。所以我试着设计了一些!
基本类型#
在设计类型时,我做的第一件事就是把基本类型弄清楚。在这里,我设计了一个Command ,该类型有两个方法。
type Command = {
option(command: string, description?: string): Command
opts(): Record<string, any>
}
option接收一个字符串类型的命令和一个可选的描述。它又返回 。这就是我们描述流畅接口的方式。Commandopts给我们的结果。现在它是一个带有字符串键的 。所以它是Record任何对象。TypeScript将只是让你通过一旦你访问带有key的props。
坦率地说,这些类型并不那么有用。但我们已经开始了。
接下来,我们还要创建构造函数,创建一个Command 对象。
type Commander = {
create(): Command
}
没有什么不寻常的地方。让我们声明一个类(这样我们就不需要为实现而烦恼了),看看我们已经能做什么了。
declare const Commander: Commander;
const program = Commander.create();
没什么可做的。另外,API也不是我们所期望的那样。我们不想调用Commander.create() 。我们想实例化一个新的类。
const program = new Commander();
实现这个目标是非常容易的。看看这个吧。
type Commander = {
- create(): Command
+ new(): Command
}
一行。我们只需要改变一行就可以了。new() 函数告诉TypeScript这是一个实际的构造函数,这意味着我们可以调用new Commander() 来实例化一个新类。这样做是因为JavaScript中的每个类都给你两个接口:一个是静态部分和构造函数,一个是实例的元素。这与在有类之前原型和构造函数的工作方式有相似之处。
所以现在这个工作已经完成了,我们想为我们创建的实例创建更好的类型。
添加泛型#
这一进展的下一步是添加泛型。我们可以使用泛型来获取我们添加为参数的字符串的实际值类型或字面类型。我们用一个扩展了string 的泛型变量U 来替换第一个参数command 。
type Command = {
option<U extends string>(command: U, description?: string): Command
opts(): Record<string, any>
}
这样,我们仍然能够传递字符串,但有趣的事情发生了。每当我们输入一个字面字符串时,我们可以把类型缩小到准确的字面类型。例如,看一下这个身份函数。
function identity<T>(t: T):T { return t }
const x = identity<string>("Hello World")
const y = identity("Hello World")
它的唯一目的是将T ,并返回相同的值。如果我们像第一个例子中那样将类型变量实例化,那么返回值的类型--x 的类型--也是string 。在第二个例子中,我们让TypeScript通过用法来推断。第二个例子的返回类型--y 的类型--是字面字符串"Hello World" 。所以每个值也是一个类型。而我们可以通过使用通用类型变量来获得这个类型。这是我想关于泛型变量最重要的一课。如果你把一件事带回家,那就是这个。
回到我们的例子。因此,每次调用.option ,我们都会将字面字符串绑定到U 。我们现在需要收集这个字面字符串,并在每次使用时将其传递。为了做到这一点,我们添加了另一个通用类型变量T 作为累加器。
type Command<T> = {
option<U extends string>(command: U, description?: string): Command<T>
opts(): Record<string, any>
}
并用空对象实例化这个通用类型变量。
type Commander = {
new(): Command<{}>
}
现在,每调用一次option ,我们就取U ,并把它加到空对象中。我们现在使用一个Record 。
type Command<T> = {
option<U extends string>(command: U, description?: string)
: Command<T & Record<U, any>>
opts(): T
}
当调用opts() ,我们也会返回T 。记住,T 存储了我们累积的选项。效果如何?看看吧。
const opts = program
.option("episode", "Download episode No. <number>")
.option("keep", "Keeps temporary files")
.option("ratio", "Either 16:9, or a custom ratio")
.opts();
当调用opts() ,我们得到一个如下类型的对象。
const opts:
Record<"episode", any> &
Record<"keep", any> &
Record<"ratio", any>
这意味着我们可以用键episode,keep, 和ratio 来访问opts。酷,这非常接近于真实的交易!
更进一步#
但我们还没有达到目的。commander 的API要高级得多。我们可以写man pages!我们可以使用双破折号来告诉我们的意图。
const opts = program
.option("--episode", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio", "Either 16:9, or a custom ratio")
.opts();
在目前的类型中,opts 的类型看起来是这样的。
const opts:
Record<"--episode", any> &
Record<"--keep", any> &
Record<"--ratio", any>
这意味着我们会像这样访问我们的选项:opts["--episode"] 。这并不酷。让我们改进一下!
我们不使用Record 来收集键,而是用一个新的类型ParseCommand<T> 来代替它。
type Command<T> = {
option<U extends string>(command: U, description?: string)
: Command<T & ParseCommand<U>>
opts(): T
}
ParseCommand 是一个条件类型,看起来像这样。
type ParseCommand<T extends string> =
T extends `--${string}` ? { [k in T]: boolean } : never;
我们检查T ,它扩展了string ,如果我们传递的T 扩展了一个以"--" 开始的字符串。我们说 "你是所有以双破折号开头的字符串的一个子集吗"?如果这个条件为真,我们就返回一个对象,在这个对象中我们把T 加入到我们的键中。由于我们每次调用.option() ,只传递一个字面字符串,我们有效地检查这个字符串是否以两个破折号开始。在所有其他情况下,我们返回never 。never 很好,因为它告诉我们,我们处于一个永远不可能发生的情况。与never的交集使整个类型的never。我们根本无法从opts 中访问任何键。这很好!它告诉我们,我们在这里添加了一些东西。它告诉我们,我们在.option 中添加了一些东西,可能会导致错误。我们的软件不会工作,TypeScript通过在我们想要使用结果的地方添加红色的斜线来告诉我们!
再来一个条件类型,还是没有进展。我们不仅对我们的字符串是否以两个破折号开始感兴趣,我们还对这些破折号之后的部分感兴趣。我们可以指示TypeScript从这个条件中获取那个字面类型,推断出字面类型,并使用这个字面类型代替。
type ParseCommand<T extends string> =
T extends `--${infer R}` ? { [k in R]: boolean } : never;
通过这一行的改变,我们完成了我们的类型。只需两行代码,我们就可以写出这样的东西。
const opts = program
.option("--episode", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio", "Either 16:9, or a custom ratio")
.opts();
并得到一个看起来像这样的类型。真是太漂亮了。
const opts: {
episode: boolean;
} & {
keep: boolean;
} & {
ratio: boolean;
}
但是我们不仅要检查标志,而且还要有可选的或必须的参数。我们可以用更多的用例来扩展我们的字符串模板字面类型,剥去双破折号。
type ParseCommand<T extends string> =
T extends `--${infer R} <${string}>` ?
{ [k in R]: string } :
T extends `--${infer R} [${string}]` ?
{ [k in R]: boolean | string } :
T extends `--${infer R}` ?
{ [k in R]: boolean } :
never;
嵌套的条件类型可以检查字符串模板字面类型。哇!好大的口气。好大的口气。其结果是。我们写这样的东西
const opts = program
.option("--episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();
并得到这个类型的opts 。
const opts: {
episode: string;
} & {
keep: boolean;
} & {
ratio: string | boolean;
}
惊艳!
更加奢侈!有了嵌套的字符串模板字面类型和嵌套的条件类型中的字符串模板字面类型内的空字符串的联合类型--呼吸,呼吸--我们甚至可以检查可选的快捷方式。
type ParseCommand<T extends string> =
T extends `${`-${string}, ` | ""}--${infer R} <${string}>` ?
{ [k in R]: string } :
T extends `${`-${string}, ` | ""}--${infer R} [${string}]` ?
{ [k in R]: boolean | string } :
T extends `${`-${string}, ` | ""}--${infer R}` ?
{ [k in R]: boolean } :
never;
因此,当我们写这样的东西时。
const opts = program
.option("-e, --episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();
哈......不,自己检查一下。到操场上去试试吧。
收敛点#
我们得到的是通过使用灵活的、基于字符串的API而生存的程序的类型安全。我们将字符串类型转变为强类型。所有这些只用了几行代码和TypeScript的一些更高级的功能。
有了所有这些力量,我想知道。我们是否已经达到了一个汇合点?我们可以通过TypeScript类型来表达每个JavaScript程序吗?
答案是:不。不,TypeScript很强大,毫无疑问。但有一件事我对你隐瞒了,那就是这些类型之所以如此好用,只是因为我以一种特定的方式使用它们。当我坚持使用构建器模式时,一切都很顺利。如果我以不同的方式使用我的程序,我最终会处于一种我无法通过类型来表达的状态。甚至不能用断言签名。
program
.option("-e, --episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files");
program
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
const opts = program.opts(); // The empty object :-(
嗯,至少现在还没有。TypeScript的目标是使尽可能多的JavaScript可以通过其类型系统来表达。正如你所看到的,我们已经走得很远了。如果像这样的用例变得更加流行,TypeScript将不可避免地添加一个功能来支持它。TypeScript赶上JavaScript是可以的。它总是这样。JavaScript的灵活性使我们获得了美妙的API,帮助我们创建好的程序,不断地使新人的门槛降低,使jQuery、express.js或Gulp等库变得如此流行。我喜欢即使在2022年,我也能为一个可爱的小库(如commander )感到兴奋。我很高兴看到TypeScript在这样的情况下会有什么变化。