我需要学习 TypeScript 模板字面量类型
今天是新西兰的星期天,我还不想起床,所以我要去听Menzingers 的新专辑,了解TypeScript 模板文字类型,并把我学到的东西写下来!
TypeScript 字符串类型:
让我们从我已经知道的内容开始。
- TypeScript 有一个
string
类型。它涵盖所有字符串,例如const hello = "Hello World";
、 或const myName = `My name is ${name}`;
。 - 您还可以使用字符串文字类型,例如
type Hello = 'hello'
,它仅匹配特定的字符串。 - 你可以使用联合类型来组合字符串字面量类型,以便更精确地确定允许的字符串输入。一个很好的例子是
type Events = 'click' | 'doubleclick' | 'mousedown' | 'mouseup' | ...;
TypeScript 所能识别的内容是有限的。模板字符串会导致特定的字符串类型扩展为泛型string
类型:
type A = 'a';
const a: A = `${'a'}`; // Argument of type 'string' is not assignable to parameter of type '"a"'.
以我的经验来看,一旦你开始输入包含特定字符串的内容,你最终往往会重复很多内容。以Events
之前的例子为例:
type EventNames = 'click' | 'doubleclick' | 'mousedown' | 'mouseup';
type Element = {
onClick(e: Event): void;
onDoubleclick(e: Event): void;
onMousedown(e: Event): void;
onMouseup(e: Event): void;
addEventListener(eventName: Event): void;
};
如果我为类型添加新的事件名称EventNames
,我也必须更改Element
类型!大多数情况下这样做可能没问题,但可能会引起问题。
模板字面量类型“基础”
(剧透:这根本不是基础!)
我第一次看到这个 PR 时,觉得模板字面量类型很酷,大家都很兴奋!后来TypeScript 4.1 Beta 版的发行说明出来了,所以我打算先过一遍。
TypeScript 4.1 可以使用与 JavaScript 相同的语法连接类型中的字符串:
type World = "world";
type Greeting = `hello ${World}`;
// same as
// type Greeting = "hello world";
使用联合类型和连接可以实现组合:
type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
type Alignment = `${VerticalAlignment}-${HorizontalAlignment}`
declare function setAlignment(value: Alignment): void;
setAlignment("top-left"); // works!
setAlignment("middle-right"); // works!
setAlignment("top-middel"); // error!
还有一些新奇的映射语法,这意味着我可以改变Element
以前的类型:
type EventNames = 'click' | 'doubleclick' | 'mousedown' | 'mouseup';
type Element = {
[K in EventNames as `on${Capitalize<EventNames>}`]: (event: Event) => void;
} & {
addEventListener(eventName: EventNames): void;
};
// same as
// type Element = {
// onClick(e: Event): void;
// onDoubleclick(e: Event): void;
// onMousedown(e: Event): void;
// onMouseup(e: Event): void;
// addEventListener(eventName: Event): void;
//};
这相当深奥——它获取类型中的每个字符串EventNames
,将其传递给一个Capitalize
类型,然后将其添加on
到每个字符串的前面!现在,如果我向 中添加一个新的事件名称EventNames
,该Element
类型已经会反映它了!
这些新功能显然非常强大,人们已经做出了一些令人惊叹的东西,例如:
Grégory Houllier将其中一些示例收集到一个地方,因此我可以通过查看实现来了解它们的工作原理!
类型安全字符串点表示法:
它起什么作用?
const user = {
projects: [
{ name: "Cool project!", contributors: 10 },
{ name: "Amazing project!", contributors: 12 },
]
};
get(user, "projects.0.contributors"); // <- I want this string to be type-safe!
我以为从一个简单的开始,但它仍然相当复杂!我稍微简化了它(可能把它弄坏了),但它更容易理解——我的实现在这里。
它是如何工作的?
我先看PathValue
一下。
type PathValue<T, P extends Path<T>> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? Rest extends Path<T[Key]>
? PathValue<T[Key], Rest>
: never
: never
: P extends keyof T
? T[P]
: never;
这是采用对象和对象的有效路径并返回该路径末尾值的类型的代码。
条件类型确实很难处理,所以我将按照我的想法重写它。
PathValue
是一个泛型类型,所以它有点像一个类型函数,它接受两个参数,T
可以是任何东西,并且P
必须是有效的Path
for T
。PathValue
它也是一个条件类型——它的形状是A extends B ? C : D
。在这种情况下,它有多个嵌套的条件!但是每个位都是never
一个不返回类型的条件,所以我可以将其简化为两个有效的条件路径。看起来像这样:
typefunction PathValue (T, P: Path<T>) {
if (P extends `${infer Key}.${infer Rest}` && Key extends keyof T && Rest extends Path<T[Key]>) {
return PathValue<T[Key], Rest>;
}
if (P extends keyof T) {
return T[P];
}
}
由于第一个条件实际上会PathValue
再次调用,因此这是一个递归条件类型🤯🤯🤯。有两个基本条件,一个继续递归,另一个结束递归。同样,我先来看“更简单”的那个。
if (P extends keyof T) {
return T[P];
}
如果P
只是一个字符串,并且它是 的精确键T
,则返回该键的类型。这意味着这是路径的终点,可以停止递归。
另一个条件是神奇的一点。
if (P extends `${infer Key}.${infer Rest}` && Key extends keyof T && Rest extends Path<T[Key]>) {
return PathValue<T[Key], Rest>;
}
以下是新奇之处:
P extends `${infer Key}.${infer Rest}`
这个类型的意思是“检查字符串是否包含'.'
,并给出 两边的字符串字面量类型'.'
”。等效的 JavaScript 代码如下:
const [Key, Rest] = P.split('.');
条件的下一部分采用第一个字符串文字(Key
)并确保它是 T 的有效键:
Key extends keyof T
条件的最后一部分采用第二个字符串文字(Rest
)并使其Path
对于类型有效T[Key]
。
因此,就本例而言:
const user = {
projects: [
{ name: "Cool project!", contributors: 10 },
{ name: "Amazing project!", contributors: 12 },
]
};
get(user, "projects.0.contributors");
如果这些条件全部为真,则递归继续,并转到对象中的级别,以及点符号字符串的下一块。
这很有道理,我现在明白了P extends `${infer Key}.${infer Rest}`
这似乎很重要。接下来是Path
类型:
type Path<T, Key extends keyof T = keyof T> =
Key extends string
? T[Key] extends Record<string, any>
? | `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`
| `${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`
| Key
: never
: never;
我将再次以不同的方式来写它:
typefunction Path<T, Key extends keyof T = keyof T> {
if (Key extends string && T[Key] extends Record<string, any>) {
return `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}` | `${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}` | Key;
}
}
这表示它是一个字符串,并且类型(又称)Key
上的属性的类型是,然后返回一些奇特的并集。该并集包含三个部分:T
T[Key]
Record
`${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`
`${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`
Key;
这是什么Exclude<keyof T[Key], keyof Array<any>>
意思?它使用了 TypeScript 的内置Exclude
类型,该类型会从第一个参数中删除第二个参数中的所有类型。在这个特定示例中,它将删除数组的所有有效键(例如push
、map
、slice
)。我猜这也包括 Object 键,但我不太清楚它是如何工作的。我觉得这个有点好,因为它可以稍微减少最终可能的路径集,但我暂时可以忽略它。这样我就得到了:
`${Key}.${Path<T[Key], keyof T[Key]> & string}`
`${Key}.${keyof T[Key] & string}`
Key;
这个& string
有点小技巧,可以简化keyof T[Key]
为只有一个string
- ,我想是因为你也可以使用符号键。所以我也可以忽略它:
所以最终的联合基本上是:
`${Key}.${Path<T[Key], keyof T[Key]>}` | `${Key}.${keyof T[Key]}` | Key;
这是另一种递归类型,其中每一层递归都会连接有效的键路径,例如`${Key}.{Path}`
,因此你会得到`${Key}.{Path}` | ${Key}.{(`${Key}.{Path})`} | `${Key}.{(`${Key}.{Path})`}`
……等等。它处理所有深层嵌套的键。它与下一层键${Key}.${keyof T[Key]}
和当前键相结合Key
。
因此,从高层次来看,有两种递归类型。一种是使用模板字面量类型 (Template Literal Types) 递归遍历对象的有效键,构建整个有效集合,并使用 连接键"."
。另一种是拆分连接的键,并计算路径每一层的类型。我觉得很有道理?如果你把它隐藏在库中一个很棒的 API 后面,它就相当强大了。
类型安全的 document.querySelector:
它起什么作用?
这个有点不同,因为它没有验证字符串是否是有效的 CSS 选择器(尽管我很确定使用这些新类型可以做到这一点),但它确实找出了查询结果的最佳类型:
const a = querySelector('div.banner > a.call-to-action') //-> HTMLAnchorElement
const b = querySelector('input, div') //-> HTMLInputElement | HTMLDivElement
const c = querySelector('circle[cx="150"]') //-> SVGCircleElement
const d = querySelector('button#buy-now') //-> HTMLButtonElement
const e = querySelector('section p:first-of-type'); //-> HTMLParagraphElement
它是如何工作的?
让我们首先看一些辅助类型:
type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;
type TrimLeft<V extends string> = V extends ` ${infer R}` ? TrimLeft<R> : V;
type TrimRight<V extends string> = V extends `${infer R} ` ? TrimRight<R> : V;
type Trim<V extends string> = TrimLeft<TrimRight<V>>;
这些非常聪明,并且似乎它们可以Capitalize
与基本 TypeScript 类型等共存。
分裂:
type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
我将再次重写它:
typefunction Split<S extends string, D extends string> {
if (S extends `${infer T}${D}${infer U}`) {
return [T, ...Split<U, D>];
}
return [S];
}
因此,还有另一种递归类型,它接受一个输入字符串和一些分割字符串D
。如果输入字符串包含分割字符串,则将分割字符串之前的部分放入一个数组中,然后将字符串的第二部分Split
再次传递给该类型。结果为 splatted( ...
),这意味着最终结果将是一个扁平化的字符串数组。
如果输入字符串不包含分割符,则返回整个字符串。它被包装在一个数组中,以便分割符能够正常工作。
采取最后:
type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;
这个跟模板类型没什么特别的关系,但还是挺有意思的。重写之后我得到了如下结果:
typefunction TakeLast<V> {
if (V extends []) {
return;
}
if (V extends [string]) {
return V[0];
}
if (V extends [string, ...infer R]) {
return TakeLast<R>;
}
}
我可能会对此做出的一个更改是使用type TakeLast<V>
? typefunction TakeLast<V extends Array<string>>
。这将限制有效的输入类型,并可能给出更容易的错误信息。
这里有三条不同的路径:
1) 如果数组为空,则不返回任何内容。2
) 如果数组包含一个元素,则返回该元素。3
) 如果数组包含多个元素,则跳过第一个元素并调用TakeLast
剩余元素的数组。
修剪左/修剪右/修剪:
type TrimLeft<V extends string> = V extends ` ${infer R}` ? TrimLeft<R> : V;
type TrimRight<V extends string> = V extends `${infer R} ` ? TrimRight<R> : V;
type Trim<V extends string> = TrimLeft<TrimRight<V>>;
更多模板字符串类型在这里:
Trim
非常好,它只需调用TrimRight
然后TrimLeft
。
TrimLeft
并且TrimRight
基本相同,所以我只重写其中一个:
typefunction TrimLeft<V extends string> {
if (V extends ` ${infer R}`) {
return TrimLeft<R>;
}
return V;
}
我实际上会再次重写它,因为它实际上的作用是:
typefunction TrimLeft<V extends string> {
if (V.startsWith(' ')) {
return TrimLeft<R>;
}
return V;
}
此类型会递归,直到找到不以空格开头的字符串。这很合理,但将其视为一种类型仍然很酷。
TrimRight
几乎完全相同,但它的endsWith
作用却不同。
StripModifiers
我在这里要看的模板类型魔法的最后一点是:
type StripModifier<V extends string, M extends string> = V extends `${infer L}${M}${infer A}` ? L : V;
type StripModifiers<V extends string> = StripModifier<StripModifier<StripModifier<StripModifier<V, '.'>, '#'>, '['>, ':'>;
可以将其重写为如下形式:
typefunction StripModifier<V extends string, M extends string> {
if (V.contains(M)) {
const [left, right] = V.split(M);
return left;
}
return V;
}
然后,该StripModifiers
类型仅使用StripModifier
可以跟在 CSS 中的元素标签名称后面的每个字符的类型:
typefunction StripModifiers<V extends string> {
StripModifier(V, '.');
StripModifier(V, '#');
StripModifier(V, '[');
StripModifier(V, ':');
}
本示例的其余部分使用这些不同类型根据相关字符(' '、'>' 和 ',')拆分 CSS 选择器,然后选择剩余选择器的相关位并返回正确的类型。
很多繁重的工作都是由这种类型的人完成的:
type ElementByName<V extends string> =
V extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[V]
: V extends keyof SVGElementTagNameMap
? SVGElementTagNameMap[V]
: Element;
它从字符串(例如“a”)映射到类型(例如HTMLAnchorElement
),然后检查 SVG 元素,最后恢复到默认Element
类型。
下一步是什么?
接下来的例子会越来越疯狂,所以我不会把我所有的想法都写下来——不过你应该自己去看看,看看能不能明白它们的工作原理。JSON解析器可能是复杂性和可读性的最佳结合。
由此我有几个想法:
1) 我绝对应该使用它来 2) TypeScript 可能很快就会需要新的类型语法,因为类似这样的东西:TSQuery
type ParseJsonObject<State extends string, Memo extends Record<string, any> = {}> =
string extends State
? ParserError<"ParseJsonObject got generic string type">
: EatWhitespace<State> extends `}${infer State}`
? [Memo, State]
: EatWhitespace<State> extends `"${infer Key}"${infer State}`
? EatWhitespace<State> extends `:${infer State}`
? ParseJsonValue<State> extends [infer Value, `${infer State}`]
? EatWhitespace<State> extends `,${infer State}`
? ParseJsonObject<State, AddKeyValue<Memo, Key, Value>>
: EatWhitespace<State> extends `}${infer State}`
? [AddKeyValue<Memo, Key, Value>, State]
: ParserError<`ParseJsonObject received unexpected token: ${State}`>
: ParserError<`ParseJsonValue returned unexpected value for: ${State}`>
: ParserError<`ParseJsonObject received unexpected token: ${State}`>
: ParserError<`ParseJsonObject received unexpected token: ${State}`>
相当棘手😅。
总而言之,这对我来说非常有用,我想我现在有点明白模板字面量类型的工作原理了。下次我尝试使用它们的时候再看看吧。
让我知道这是否有用,这是一个未经过滤和编辑的内容🙃
文章来源:https://dev.to/phenomnominal/i-need-to-learn-about-typescript-template-literal-types-51po