我如何告诉 Typescript 我期望 split() 正好有 2 个元素?

How do I tell Typescript that I expect exactly 2 elements from split()?

我想键入一个数组数组,其中每个元素有两个或四个数字。

[
  [ 1, 2 ],
  [ 1, 2 ],
  [ 1, 2, 3, 4]
]

我已经声明了这些类型。

type Point = [number, number];
type Line = [number, number, number, number];
type Record = Array< Line | Point >; 

但是当我尝试从一串逗号分隔的数字中生成 Point 时,出现错误。

const foo:Point = "1,2".split(",").map(parseInt);

Type 'number[]' is not assignable to type 'Point'. Target requires 2 element(s) but source may have fewer.ts(2322)

我知道它无法知道 split() returns 是否恰好是 2 个元素。我可以将 Point 设为 number[],但感觉它违背了强类型系统的要点。

我试过 split(pattern, 2),但这并没有什么不同,而且我也不知道如何说“拆分为 2 或 4 个元素”。

const foo:Point = "1,2"
  .split(",", 2)
  .map(parseInt)
  .map((e) => [e[0], e[1]]); // .slice(0, 2) doesn't work either

以上看起来实际上恰好有两个元素,但它也不起作用。

我如何说服它从 split() 返回两个数字?

前言:您不能可靠地直接使用 parseInt 作为 map 回调,因为 parseInt 接受多个参数并 map 传递它多个参数。您必须将其包装在箭头函数或类似函数中,或者改用 Number 。我在这个答案中完成了后者。

这取决于您希望达到的彻底程度和类型安全程度。

您可以断言它是 Point,但在大多数情况下我不会:

// I wouldn't do this
const foo = "1,2".split(",").map(Number) as Point;

问题是如果字符串没有定义两个元素,则断言是错误的,但没有任何检查断言。现在,在这种特殊情况下,您使用的是字符串文字,因此您知道它是有效的 Point,但在一般情况下您不会。

由于您可能希望在多个地方将字符串转换为 Point 个实例,因此我将编写一个实用程序函数来检查结果。这是一个版本:

const stringToPoint = (str: string): Point => {
    const pt = str.split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
    if (pt.length !== 2) {
        throw new Error(`Invalid Point string "${str}"`);
    }
    return pt as Point;
};

检查数组是否有两个元素,如果没有则抛出错误,所以它后面的类型断言是有效的。

这就是您想要做到多彻底的问题所在。您可能希望有一个单一的中央权威方法来检查某物是否 Point,尤其是这样您可以在以下情况下更改它您对 Point 的定义发生了变化(在这种情况下不太可能)。这可能是 type predicate(“类型保护函数”)或类型断言函数。

这是一个类型谓词:

function isPoint(value: number[]): value is Point {
    return value.length === 2;
}

然后stringToPoint变成:

const stringToPoint = (str: string): Point => {
    const pt = str.split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
    if (!isPoint(pt)) {
        throw new Error(`Invalid Point string "${str}"`);
    }
    return pt;
};

Playground link

如果你想要一个,这里有一个类型断言函数,你可以在你认为你知道该值是有效的地方使用Point:

function assertIsPoint(value: number[]): asserts value is Point {
    if (!isPoint(value)) {
        throw new Error(`Invalid Point found: ${JSON.stringify(value)}`);
    }
}

这让你可以做这样的事情:

const pt = "1,2".split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
assertIsPoint(pt);
// Here, `pt` has the type `Point`

Playground link

我不会按字面意思那样做(我会使用 stringToPoint),但是当(比如说)你正在反序列化一些绝对正确的东西(例如,Point 你从 localStorage 得到并通过 JSON.parse).

解析

除了@T.J。 Crowder的解决方案,非常好,还有一个,更侧重于类型推断。

我们可以编写一个函数来推断 return 提供的参数类型。例如:


split('1,2') // [1,2]

为了实现它,我们需要编写一个实用程序类型来解析字符串并将所有字符串化数字转换为数值数组。 我们需要:

  1. 生成一个数字范围
  2. 添加用于将字符串化数字转换为数字的实用程序类型
  3. 编写一个类型,它将遍历传递的参数中的每个字符,将其转换为 number 并将其添加到列表中。

要生成数字范围,你可以查看我的article and :

type MAXIMUM_ALLOWED_BOUNDARY = 100

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? [...Result, Result['length']][number]
        : ComputeRange<N, [...Result, Result['length']]>
    )

type ResultRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY> // 1 | 2 | 3 .. 100

然后,我们需要将字符串化数字转换为数字:

type ToInt<
    DigitRange extends number,
    Digit extends string
    > =
    /**
     * Every time when you see: [Generic] extends any ...
     * It means that author wants to turn on distributivity 
     * https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
     */
    DigitRange extends any
    /**
     * After Distributivity was turned on, it means that [true branch] is applied to 
     * each element of the union separately
     * 
     * SO, here we are checking whether wrapped into string numerical digit is assignable to stringified digit
     */
    ? `${DigitRange}` extends Digit
    /**
     * If yes, we return numerical digit, without string wrapper
     */
    ? DigitRange
    : never
    : never

type Result = ToInt<1 | 2 | 3 | 4 | 5, '2'> // 2

现在,我们需要一个类型来遍历字符串中的每个字符并将其转换为数字:

type Inference<
    S extends string,
    Tuple extends number[] = []
    > =
    /**
     * Check if it is the end of string
     */
    S extends ''
    /**
     * If it is the end - return accumulator generic
     */
    ? Tuple
    /**
     * Otherwise infer first and second digits which are separated by comma and rest elements
     */
    : S extends `${infer Fst},${infer Scd}${infer Rest}`
    /**
     * Paste infered digits into accumulator type Tuple and simultaneously convert them into numerical digits
     */
    ? Inference<
        Rest, [...Tuple, IsValid<Fst>, IsValid<Scd>]
    >
    : never

全孔示例:


type MAXIMUM_ALLOWED_BOUNDARY = 100

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? [...Result, Result['length']][number]
        : ComputeRange<N, [...Result, Result['length']]>
    )

type ResultRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY> // 1 | 2 | 3 .. 100

type ToInt<
    DigitRange extends number,
    Digit extends string
    > =
    DigitRange extends any
    ? `${DigitRange}` extends Digit
    ? DigitRange
    : never
    : never

type Result = ToInt<1 | 2 | 3 | 4 | 5, '2'> // 2

type IsValid<Elem extends string> = ToInt<ResultRange, Elem>


type Inference<
    S extends string,
    Tuple extends number[] = []
    > =
    S extends ''
    ? Tuple
    : S extends `${infer Fst},${infer Scd}${infer Rest}`
    ? Inference<
        Rest, [...Tuple, IsValid<Fst>, IsValid<Scd>]
    >
    : never

declare const split: <Str extends `${number},${number}`>(str: Str) => Inference<Str>

split('1,2') // [1,2]

Playground

您可能已经注意到,我更多地关注类型而不是业务逻辑。希望对理解 TS 类型系统的工作原理有所帮助。