英文:
Typescript - Type definition for an array of objects from union type
问题
我想为来自联合类型或枚举类型的对象数组创建一种类型定义。如果联合类型中的所有条目都不作为对象数组中的键的值存在,则希望该类型定义失败。
export type SelectValue<T = any> = {
value: T;
label: string;
};
type options = "email" | "sms";
// 理想情况下,此类型检查应失败,因为它不包含 options 联合类型的所有值
const values: SelectValue<options>[] = [
{
value: "email",
label: "Email",
},
];
英文:
I want to create a type definition for an array of objects from a union or enum type. I want the type definition to fail if not all entries in the union exist in the array as a value of a key in the array of objects.
export type SelectValue<T = any> = {
value: T;
label: string;
};
type options = "email" | "sms";
//ideally make this type check fail because it does not have all of the values of the options union
const values: SelectValue<options>[] = [
{
value: "email",
label: "Email",
},
];
答案1
得分: 1
TypeScript没有内置类型对应于"exhaustive array",即保证某个联合类型的每个成员都在数组中存在。如果联合类型中的成员数量较少且不允许重复,那么你可以生成所有可能的可接受的tuple类型的联合类型,例如[SelectValue<"email">, SelectValue<"sms">] | [SelectValue<"sms">, SelectValue<"email">]
,就像你的示例代码中所示(请参见此处)。但是如果成员数量较多,或者你想允许重复,这种方法在性能上会很差,因为可能的元组联合类型的数量会急剧增加,即使它自身也会变得非常大。TypeScript的联合类型最多只能包含约10万个元素,而在此之前编译器就会明显变慢。这意味着如果你的联合类型中甚至有八个元素,性能也会受到影响。
在TypeScript中,最接近的方法是编写一个泛型类型,它作为数组类型的约束。也就是说,没有针对联合类型 U
的 ExhaustiveArray<U>
类型;相反,有一个泛型类型 ExhaustiveArray<T, U>
,其中 T extends ExhaustiveArray<T, U>
当且仅当 T
是一个完全包含 U
所有成员的数组时。你需要一个辅助函数来避免手动编写 T
,而是使用 const arr = exhaustiveArrayForMyUnion(...)
。
让我们来定义这个类型:
type ExhaustiveArray<T extends readonly any[], U> =
[U] extends [T[number]] ? T : [...T, Exclude<U, T[number]>]
const exhaustiveArray = <U>() => <T extends readonly U[]>(
...t: [...T extends ExhaustiveArray<T, U> ? T : ExhaustiveArray<T, U>]
) => t as T;
这里的 ExhaustiveArray<T, U>
是一个条件类型,它检查联合类型 U
是否完全被数组 T
中的所有元素所包含。如果是,它将评估为 T
(因为T extends T
,这将成功)。如果不是,它将评估为在末尾具有比 T
多一个元素的元组,包含了所有缺失的元素(使用可变元组类型在末尾添加一个元素,以及使用Exclude
实用工具类型来计算缺失的元素)。
exhaustiveArray
值是一个柯里化的辅助函数,它接受一个联合类型 U
并生成一个新函数,该函数从传递的值中推断 T
,然后进行检查。这种奇怪的编写方式的目的是在传递非完整的数组时获得"好的"错误消息。"坏的"错误消息是编译器只会说"该值不能赋给 never
",这很烦人,因为它不会帮助开发人员知道如何修复它。
好的,让我们测试一下。首先,让我们获取 exhaustiveSelectValueArray()
函数:
const exhaustiveSelectValueArray = exhaustiveArray<
{ [K in Options]: SelectValue<K> }[Options]
>();
这里的类型参数是一个分布式映射类型,将 Options
联合类型 O1 | O2 | O3 | ... | ON
转换为 SelectValue<O1> | SelectValue<O2> | SelectValue<O3> | ... | SelectValue<ON>
。
现在让我们来测试一下:
const values = exhaustiveSelectValueArray(
{ value: "email", label: "Email" },
{ value: "sms", label: "SMS" }
); // 正常
const err = exhaustiveSelectValueArray(
{ value: "email", label: "Email" }
); // 错误!期望 2 个参数,但只提供了 1 个。
const err2 = exhaustiveSelectValueArray(
{ value: "email", label: "Email" },
{ value: "email", label: "SMS" }
); // 错误!期望 3 个参数,但只提供了 2 个。
第一个调用成功,因为两个元素都传递了进去,而后面两个调用失败,因为缺少一个参数。如果使用 IntelliSense 来检查函数调用,以查看缺少哪个参数,两者都会显示期望的参数类型是 SelectValue<"sms">
,而没有在末尾提供。
英文:
TypeScript doesn't have a built-in type corresponding to an "exhaustive array" in which every member of some union type is guaranteed to exist somewhere in the array. Nor can you easily create your own specific type that works this way, at least not if the union has a lot of members in it or if you want to allow duplicates.
For a union with only a handful of members and if you want to prohibit duplicates, then you can could generate a union of all possible acceptable tuple types, like [SelectValue<"email">, SelectValue<"sms">] | [SelectValue<"sms">, SelectValue<"email">]
for your example code (see here). But that scales very badly in the number 𝑛 of members; the union of possible tuples containing one element for each member will itself have 𝑛! members (that's 𝑛 factorial), which gets very big, very quickly. Unions in TypeScript can only hold about 100,000 elements at most, and the compiler slows down noticeably before that. This means if you have even eight elements in your union, you'll have a bad time.
Instead the closest you can get in TypeScript is to write a generic type which acts as a constraint on the array type. That is, there's no ExhaustiveArray<U>
type for union U
; instead there's a generic ExhaustiveArray<T, U>
type where T extends ExhaustiveArray<T, U>
if and only if T
is an array that exhausts all the members of U
. And you'd need a helper function to stop you from writing out T
yourself. That is, instead of const arr: ExhaustiveArray<MyUnion> = [...]
, you'd write const arr = exhaustiveArrayForMyUnion(...)
.
So let's define this:
type ExhaustiveArray<T extends readonly any[], U> =
[U] extends [T[number]] ? T : [...T, Exclude<U, T[number]>]
const exhaustiveArray = <U,>() => <T extends readonly U[]>(
...t: [...T extends ExhaustiveArray<T, U> ? T : ExhaustiveArray<T, U>]) => t as T;
Here the ExhaustiveArray<T, U>
is a conditional type which checks to see if the union U
is completely accounted for by the union of all the elements of the array T
. If so, it evaluates to T
(and since T extends T
, this will be a success). If not, it evaluates to a tuple with one more element than T
at the end, containing everything missing (using variadic tuple types to append an element, and the Exclude
utility type to compute the missing elements).
And the exhaustiveArray
value is a curried helper function which takes a union type U
and produces a new function that infers T
from the value passed in, and then does the check. It's written in a weird way; it would be great if we could write <T extends ExhaustiveArray<T, U>>(...t: [...T]) => t
, but that's illegally circular. Or if <T extends readonly U[]>(...t: [...ExhaustiveArray<T, U>]) => t
worked, that would be great, but the compiler will not be able to infer T
from t
that way. The version above causes the compiler to first infer T
from the value of t
, and then convert that into ExhaustiveArray<T, U>
. The whole point of this weird approach is to get a "good" error message when you pass in non-exhaustive arrays. A "bad" error message would be if the compiler would just say "that value isn't assignable to never
", which is annoying because it doesn't help the developer know how to fix it.
Okay, let's test. First let's get the exhaustiveSelectValueArray()
function:
const exhaustiveSelectValueArray = exhaustiveArray<
{ [K in Options]: SelectValue<K> }[Options]>();
where the type argument is a distributive mapped type that converts the Options
union O1 | O2 | O3 | ... | ON
into SelectValue<O1> | SelectValue<O2> | SelectValue<O3> | ... | SelectValue<ON>
.
And here goes:
const values = exhaustiveSelectValueArray(
{ value: "email", label: "Email" },
{ value: "sms", label: "SMS" }
); // okay
const err = exhaustiveSelectValueArray(
{ value: "email", label: "Email" }
); // error! Expected 2 arguments, but got 1.
const err2 = exhaustiveSelectValueArray(
{ value: "email", label: "Email" },
{ value: "email", label: "SMS" }
); // error! Expected 3 arguments, but got 2.
The first call succeeds because both elements are passed in, while the second two calls fail because we're missing an argument. If you use IntelliSense to examine the function calls to see what argument is missing, both show that there was an expected argument of type SelectValue<"sms">
that wasn't passed in at the end.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论