创建一个表示字符串类型首字母的 TypeScript 类型

Make a TypeScript type which represents the first letter of a string type

我有一个函数可以输出传递给它的字符串的第一个字母。在我的例子中,我知道可能的值是什么,假设是硬编码或通过泛型,并且希望函数的 return 类型与被 returned 的字母完全相同,所以我可以通过这将用于以后的功能。

我实际上找到了一种相当不优雅的方式来做到这一点,但我觉得它不稳定并且可能无法在未来版本的 TypeScript 中工作,因为 ${infer FirstLetter} 在技术上可以表示任意数量的字符......碰巧 TypeScript 目前只找到第一个:

type Speed = 'fast' | 'slow' | 'medium';
type SpeedShort = Speed extends `${infer FirstLetter}${string}`
  ? FirstLetter
  : never;

作为函数声明,它可能看起来像:

declare function firstLetter<Letters extends string>(
  string: Letters,
): Letters extends `${infer FirstLetter}${string}`
  ? FirstLetter
  : never;

声明这种类型的方法如下:

type Speed = 'fast' | 'slow' | 'medium'
type SpeedShort = 'f' | 's' | 'm'

下面是实现转换函数的方法:

function firstLetter(speed: Speed): SpeedShort {
    switch (speed) {
        case 'fast': return 'f'
        case 'slow': return 's'
        case 'medium': return 'm'
    }
}

playground

我知道写的代码比较多。 然而,这段代码仍然表达了意图,并由编译器正确验证,没有人为错误的余地(即改变一种类型而不改变映射函数将导致编译时错误)。

令人沮丧的是,类型 'test'[0] 不是 't',而是 string。 (同样,'test'['length'] 不是 4,而是 number。)显然,字符串文字类型在 Typescript 中并不像元组类型那样复杂([0]['length'] 将按预期工作 ['t', 'e', 's', 't'])。有an open issue about this limitation.

和您一样,我对 `${infer H}${infer T}` 也有些怀疑。 the docs that says it will always match exactly one character for the first H and the rest in T. There is, however, a statement in the original pull request that describes exactly this behavior里面什么都没有说:

A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

此外,in another comment, Anders says

In general, immediately adjacent placeholders are really only useful for taking strings apart one character at a time.

表明这是可以信赖的预期行为。我想在文档中找到它,但考虑到我们所拥有的,`${infer H}${string}` 可能是安全的,当然也是最好的可用情况,只要它是安全的。

但如果我们使用它,仍然有选择。不是伟大的,但它们存在。例如,对 Oleksandr 的答案的简单改进是

function firstLetter<S extends Speed>(speed: S):
  S extends 'fast' ? 'f' :
  S extends 'medium' ? 'm' :
  S extends 'slow' ? 's' :
  never {
    // implementation
}

See in Playground

这将 return 恰好是您提供的任何 Speed(s) 的第一个字母。所以 firstLetter('fast') 不会有 return 类型的 SpeedShort,它会有 f 类型。 (speed: 'fast' | 'slow') => firstLetter(speed) 将具有 return 类型的 'f' | 's'

更重要的是,这可能意味着更显着的改进:

type FirstLetterOf<S extends string> =
  string extends S ? string : // case where S is just string, not a literal type
  S extends `a${string}` ? 'a' :
  S extends `b${string}` ? 'b' :
  S extends `c${string}` ? 'c' :
  S extends `d${string}` ? 'd' :
  S extends `e${string}` ? 'e' :
  S extends `f${string}` ? 'f' :
  S extends `g${string}` ? 'g' :
  S extends `h${string}` ? 'h' :
  S extends `i${string}` ? 'i' :
  S extends `j${string}` ? 'j' :
  S extends `l${string}` ? 'l' :
  S extends `m${string}` ? 'm' :
  S extends `n${string}` ? 'n' :
  S extends `o${string}` ? 'o' :
  S extends `p${string}` ? 'p' :
  S extends `q${string}` ? 'q' :
  S extends `r${string}` ? 'r' :
  S extends `s${string}` ? 's' :
  S extends `t${string}` ? 't' :
  S extends `u${string}` ? 'u' :
  S extends `v${string}` ? 'v' :
  S extends `w${string}` ? 'w' :
  S extends `x${string}` ? 'x' :
  S extends `y${string}` ? 'y' :
  S extends `z${string}` ? 'z' :
  string;

function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }

See in Playground

这将提取第一个字母...只要我们将“字母”定义为a-z。当然,您可以扩展它……但只能扩展到一定程度。 Typescript 的嵌套条件限制相当低,已经有 27 层了。添加大写字母,您最多可以得到 53 个。

在这种情况下,解决嵌套条件问题的一个常见解决方案是使用映射类型,并遍历该映射类型,而不是嵌套所有可能性。也就是这个:

type Letter =
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
  | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
  ;

type FirstLetterOf<S extends string> =
  string extends S ? string : // case where S is just string, not a literal type
  {
    [L in Letter]: S extends `${L}${string}` ? L : never;
  }[Letter];

function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }

See in Playground

现在,我们不再有嵌套条件,我们只有一个条件,即每个 Letter 运行。现在添加字母相对容易,虽然我们可以添加的数量仍有限制,但现在 非常 大。

这仍然有一个问题:如果你输入一个不以Letter开头的字符串文字,则转弯类型是never。那是不对的;它可能应该是 string (例如,我们的类型不够聪明,无法缩小 which 字符串的范围,但我们最终会得到一)。解决这个问题……看起来很糟糕,但它仍然保持高性能并且不受嵌套限制的影响:

type Letter =
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
  | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
  ;

type FirstLetterOf<S extends string> =
  string extends S ? string : // case where S is just string, not a literal type
  {
    [L in Letter]: S extends `${L}${string}` ? L : never;
  }[Letter] extends infer L
    ? [L, never] extends [never, L]
      ? string
      : L
    : never;

function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }

See in Playground

extends infer L业务有效地将类型的结果保存在类型L中,然后[L, never] extends [never, L]是一种检查L是否为[=36=的方法]——也就是说,我们 Letter 中的 none 匹配。如果是这样,我们的类型就是 string。如果没有,我们就使用 L,因为这意味着 Letter 匹配,这就是我们想要使用的。最后一个 : never 是条件 blah blah extends infer L 的“假情况”,它永远不会是假的,因为我们 infer Lblah blah 实际上是什么,所以当然 blah blah 扩展了它。这里的语法……很奇怪,但它确实有效。我们这里确实有几层嵌套条件,但是它是一个固定的数字,无论我们添加多少Letter都不会改变,所以没关系。