我需要学习 TypeScript 模板字面量类型

2025-05-26

我需要学习 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"'.
Enter fullscreen mode Exit fullscreen mode

以我的经验来看,一旦你开始输入包含特定字符串的内容,你最终往往会重复很多内容。以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;
};
Enter fullscreen mode Exit fullscreen mode

如果我为类型添加新的事件名称EventNames,我也必须更改Element类型!大多数情况下这样做可能没问题,但可能会引起问题。

模板字面量类型“基础”

(剧透:这根本不是基础!)

我第一次看到这个 PR 时,觉得模板字面量类型很酷,大家都很兴奋!后来TypeScript 4.1 Beta 版的发行说明出来了,所以我打算先过一遍。

TypeScript 4.1 可以使用与 JavaScript 相同的语法连接类型中的字符串:

type World = "world";

type Greeting = `hello ${World}`;
// same as
//   type Greeting = "hello world";
Enter fullscreen mode Exit fullscreen mode

使用联合类型和连接可以实现组合:

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!
Enter fullscreen mode Exit fullscreen mode

还有一些新奇的映射语法,这意味着我可以改变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;
//};
Enter fullscreen mode Exit fullscreen mode

这相当深奥——它获取类型中的每个字符串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!
Enter fullscreen mode Exit fullscreen mode

我以为从一个简单的开始,但它仍然相当复杂!我稍微简化了它(可能把它弄坏了),但它更容易理解——我的实现在这里

它是如何工作的?

我先看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;
Enter fullscreen mode Exit fullscreen mode

这是采用对象和对象的有效路径并返回该路径末尾值的类型的代码。

条件类型确实很难处理,所以我将按照我的想法重写它。

PathValue是一个泛型类型,所以它有点像一个类型函数,它接受两个参数,T可以是任何东西,并且P必须是有效的Pathfor TPathValue它也是一个条件类型——它的形状是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];
  }
}
Enter fullscreen mode Exit fullscreen mode

由于第一个条件实际上会PathValue再次调用,因此这是一个递归条件类型🤯🤯🤯。有两个基本条件,一个继续递归,另一个结束递归。同样,我先来看“更简单”的那个。

if (P extends keyof T) {
  return T[P];
}
Enter fullscreen mode Exit fullscreen mode

如果P只是一个字符串,并且它是 的精确键T,则返回该键的类型。这意味着这是路径的终点,可以停止递归。

另一个条件是神奇的一点。

if (P extends `${infer Key}.${infer Rest}` && Key extends keyof T && Rest extends Path<T[Key]>) {
  return PathValue<T[Key], Rest>;
}
Enter fullscreen mode Exit fullscreen mode

以下是新奇之处:

P extends `${infer Key}.${infer Rest}`
Enter fullscreen mode Exit fullscreen mode

这个类型的意思是“检查字符串是否包含'.',并给出 两边的字符串字面量类型'.'”。等效的 JavaScript 代码如下:

const [Key, Rest] = P.split('.');
Enter fullscreen mode Exit fullscreen mode

条件的下一部分采用第一个字符串文字(Key)并确保它是 T 的有效键:

Key extends keyof T
Enter fullscreen mode Exit fullscreen mode

条件的最后一部分采用第二个字符串文字(Rest)并使其Path对于类型有效T[Key]

因此,就本例而言:

const user = {
  projects: [
    { name: "Cool project!", contributors: 10 },
    { name: "Amazing project!", contributors: 12 },
  ]
};
get(user, "projects.0.contributors");
Enter fullscreen mode Exit fullscreen mode

如果这些条件全部为真,则递归继续,并转到对象中的级别,以及点符号字符串的下一块。

这很有道理,我现在明白了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;
Enter fullscreen mode Exit fullscreen mode

我将再次以不同的方式来写它:

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

这表示它是一个字符串,并且类型(又称)Key上的属性的类型,然后返回一些奇特的并集。该并集包含三个部分:TT[Key]Record

`${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`

`${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`

Key;
Enter fullscreen mode Exit fullscreen mode

这是什么Exclude<keyof T[Key], keyof Array<any>>意思?它使用了 TypeScript 的内置Exclude类型,该类型会从第一个参数中删除第二个参数中的所有类型。在这个特定示例中,它将删除数组的所有有效键(例如pushmapslice)。我猜这也包括 Object 键,但我不太清楚它是如何工作的。我觉得这个有点好,因为它可以稍微减少最终可能的路径集,但我暂时可以忽略它。这样我就得到了:

`${Key}.${Path<T[Key], keyof T[Key]> & string}`

`${Key}.${keyof T[Key] & string}`

Key;
Enter fullscreen mode Exit fullscreen mode

这个& string有点小技巧,可以简化keyof T[Key]为只有一个string- ,我想是因为你也可以使用符号键。所以我也可以忽略它:

所以最终的联合基本上是:

`${Key}.${Path<T[Key], keyof T[Key]>}` | `${Key}.${keyof T[Key]}` | Key;
Enter fullscreen mode Exit fullscreen mode

这是另一种递归类型,其中每一层递归都会连接有效的键路径,例如`${Key}.{Path}`,因此你会得到`${Key}.{Path}` | ${Key}.{(`${Key}.{Path})`} | `${Key}.{(`${Key}.{Path})`}`……等等。它处理所有深层嵌套的键。它与下一层键${Key}.${keyof T[Key]}和当前键相结合Key

因此,从高层次来看,有两种递归类型。一种是使用模板字面量类型 (Template Literal Types) 递归遍历对象的有效键,构建整个有效集合,并使用 连接键"."。另一种是拆分连接的键,并计算路径每一层的类型。我觉得很有道理?如果你把它隐藏在库中一个很棒的 AP​​I 后面,它就相当强大了。

类型安全的 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
Enter fullscreen mode Exit fullscreen mode

它是如何工作的?

让我们首先看一些辅助类型:

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>>;
Enter fullscreen mode Exit fullscreen mode

这些非常聪明,并且似乎它们可以Capitalize与基本 TypeScript 类型等共存。

分裂:
type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
Enter fullscreen mode Exit fullscreen mode

我将再次重写它:

typefunction Split<S extends string, D extends string> {
    if (S extends `${infer T}${D}${infer U}`) {
        return [T, ...Split<U, D>];
    }
    return [S];
}
Enter fullscreen mode Exit fullscreen mode

因此,还有另一种递归类型,它接受一个输入字符串和一些分割字符串D。如果输入字符串包含分割字符串,则将分割字符串之前的部分放入一个数组中,然后将字符串的第二部分Split再次传递给该类型。结果为 splatted( ...),这意味着最终结果将是一个扁平化的字符串数组。

如果输入字符串不包含分割符,则返回整个字符串。它被包装在一个数组中,以便分割符能够正常工作。

采取最后:
type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;
Enter fullscreen mode Exit fullscreen mode

这个跟模板类型没什么特别的关系,但还是挺有意思的。重写之后我得到了如下结果:

typefunction TakeLast<V> {
    if (V extends []) {
        return;
    }
    if (V extends [string]) {
        return V[0];
    }
    if (V extends [string, ...infer R]) {
        return TakeLast<R>;
    }
}
Enter fullscreen mode Exit fullscreen mode

我可能会对此做出的一个更改是使用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>>;
Enter fullscreen mode Exit fullscreen mode

更多模板字符串类型在这里:

Trim非常好,它只需调用TrimRight然后TrimLeft

TrimLeft并且TrimRight基本相同,所以我只重写其中一个:

typefunction TrimLeft<V extends string> {
    if (V extends ` ${infer R}`) {
        return TrimLeft<R>;
    }
    return V;
}
Enter fullscreen mode Exit fullscreen mode

我实际上会再次重写它,因为它实际上的作用是:

typefunction TrimLeft<V extends string> {
    if (V.startsWith(' ')) {
        return TrimLeft<R>;
    }
    return V;
}
Enter fullscreen mode Exit fullscreen mode

此类型会递归,直到找到不以空格开头的字符串。这很合理,但将其视为一种类型仍然很酷。

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, '.'>, '#'>, '['>, ':'>;
Enter fullscreen mode Exit fullscreen mode

可以将其重写为如下形式:

typefunction StripModifier<V extends string, M extends string> {
    if (V.contains(M)) {
        const [left, right] = V.split(M);
        return left;
    }
    return V;
}
Enter fullscreen mode Exit fullscreen mode

然后,该StripModifiers类型仅使用StripModifier可以跟在 CSS 中的元素标签名称后面的每个字符的类型:

typefunction StripModifiers<V extends string> {
    StripModifier(V, '.');
    StripModifier(V, '#');
    StripModifier(V, '[');
    StripModifier(V, ':');
}
Enter fullscreen mode Exit fullscreen mode

本示例的其余部分使用这些不同类型根据相关字符(' '、'>' 和 ',')拆分 CSS 选择器,然后选择剩余选择器的相关位并返回正确的类型。

很多繁重的工作都是由这种类型的人完成的:

type ElementByName<V extends string> = 
    V extends keyof HTMLElementTagNameMap 
        ? HTMLElementTagNameMap[V] 
        : V extends keyof SVGElementTagNameMap 
        ? SVGElementTagNameMap[V] 
        : Element;
Enter fullscreen mode Exit fullscreen mode

它从字符串(例如“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}`>
Enter fullscreen mode Exit fullscreen mode

相当棘手😅。

总而言之,这对我来说非常有用,我想我现在有点明白模板字面量类型的工作原理了。下次我尝试使用它们的时候再看看吧。

让我知道这是否有用,这是一个未经过滤和编辑的内容🙃

文章来源:https://dev.to/phenomnominal/i-need-to-learn-about-typescript-template-literal-types-51po
PREV
如何永久免费获得 Google 服务器
NEXT
Kubernetes 入门指南 - 1