网站搜索

typescript - 我如何根据成员资格排除或选择元组?


我有一个含有嵌套元组的元组对象

const foo = [
  { id: 't1', values: ['a', 'b'] },
  { id: 't2', values: ['a', 'c'] },
  { id: 't3', values: ['b', 'c'] },
] as const;

我希望能够根据元组的值进行过滤,同时区分类型。

这个函数会给你正确的运行时值,但会产生类型错误

var objsWithA = foo.filter(({ values }) => values.includes('a'));

如果你使用 as any 或 as never 来绕过类型错误,得到的类型并不会消除 t3

var objsWithA = foo.filter(({ values }) => values.includes('a' as never));

是否有可能使结果类型与返回值匹配?即

[
  { id: 't1', values: ['a', 'b'] },
  { id: 't2', values: ['a', 'c'] },
]

ts 演示链接

答案#1

TypeScript 对使用数组 filter() 方法来缩小结果数组的类型有一定支持,可以通过调用这个签名来实现:

interface ReadonlyArray<T> {
  filter<S extends T>(
    predicate: (value: T, index: number, array: readonly T[]) => value is S,
    thisArg?: any
  ): S[];
}

意味着 predicate 参数需要是一个 自定义类型保护函数,其返回类型是一个类型谓词。

不幸的是,TypeScript 并不会从函数实现中推断类型谓词,即使是在像 `x => typeof x === "string"` 这样简单的情况下。 在 microsoft/TypeScript#38390 上有一个开放的问题请求提供这样的支持。

即使那真的发生了,它可能也不适用于 ({values})=>values.includes("a");即使直接调用 includes() 数组方法也不会作为类型保护。这个请求在 microsoft/TypeScript#31018 中被拒绝,因为实现它太复杂,难以正确处理。

这意味着你必须使用你明确编写的回调函数来调用filter(),使其成为自定义类型保护函数,并明智地使用它。这里有一种可能的方式来为比你当前情况稍微一般一些的情况生成类型保护函数:

const objPropHasArrayContainingStringLiteral =
    <K extends string, T extends string>(
        propName: K,
        stringLiteral: T
    ) => <U extends Record<K, readonly string[]>>(
        obj: U
    ): obj is (
       U extends Record<K, readonly (infer V extends string)[]> ? 
       [T] extends [V] ? U : never : never
    ) => obj[propName].includes(stringLiteral);

这里 objPropHasArrayContainingStringLiteral(propName, stringLiteral) 会生成一个类型保护函数,用于检查对象是否在 propName 处具有包含 stringLiteral 的数组属性。

类型输出使用分配条件类型来过滤联合类型 U,仅保留那些在键 K 上的属性是只读数组且包含 T 元素的成员(至少假设 T 是字符串字面量类型,并且 U 的数组类型本身也包含字符串字面量类型)。正如上面提到的,这很难正确实现。

所以对于你的例子,它将是 objPropHasArrayContainingStringLiteral("values", "a"):

const objsWithA = foo.filter(objPropHasArrayContainingStringLiteral("values", "a"));

/* const objsWithA: ({
    readonly id: "t1";
    readonly values: readonly ["a", "b"];
} | {
    readonly id: "t2";
    readonly values: readonly ["a", "c"];
})[] */

那行,太好了。但是我不确定我们在这里解决的问题是否值得使用更通用的解决方案。如果你只打算做一次,那么不如直接全部写在内联里:

const objsWithA = foo.filter(<U extends { values: readonly string[] }>(
    obj: U): obj is U extends { values: readonly (infer V extends string)[] } ?
    "a" extends V ? U : never : never => obj.values.includes("a")
);

这其实是同一件事,只不过把 K 硬编码为 "values",把 T 硬编码为 "a"。本质上,它的意思是回调会将 U 联合类型过滤为仅包含那些 values 属性为只读数组的成员,该数组可能包含类型为 "a" 的值。

请注意,编译器实际上并不会验证自定义类型保护函数的实现。你可以将 obj.values.includes("a") 改为 !obj.values.includes("a") 或 obj.values.includes("whoops"),编译器都不会发现。所以,再次提醒,你必须小心。

代码的 Playground 链接