在 TypeScript 中将字典/对象的键获取为元组

24
我想在TS 3.1中从对象中获取适当的元组类型和适当的类型字面量:
interface Person {
  name: string,
  age: number
}

// $ExpectType ['name','age']
type ObjectKeysTuple = ToTuple<keyof Person>

为什么:

当使用Object.keys(dictionary)时,希望获得适当的字符串字面量元组。

我无法找到解决方案,因为keyof会扩大到联合类型,返回('name' | 'age')[],这绝对不是我们想要的。

type ObjectKeysTuple<T extends object> = [...Array<keyof T>]
type Test = ObjectKeysTuple<Person>
// no errors not good
const test: Test = ['age','name','name','name']

相关内容:


联合可能是您在这里能做的最好的事情,除非您确切地知道dictionary键的顺序与接口中声明的键的顺序相同,而且您并不真正知道 - jcalz
3个回答

22
你提到的使用场景,为Object.keys()设计元组类型,存在很大的风险,我建议不要使用。
首先,TypeScript中的类型不是"exact"的。也就是说,仅因为我有一个类型为Person的值,并不意味着该值仅包含nameage属性。想象一下以下情况:
interface Superhero extends Person {
   superpowers: string[]
}
const implausibleMan: Superhero = { 
   name: "Implausible Man",
   age: 35,
   superpowers: ["invincibility", "shape shifting", "knows where your keys are"]
}
declare const randomPerson: Person;
const people: Person[] = [implausibleMan, randomPerson];
Object.keys(people[0]); // what's this?
Object.keys(people[1]); // what's this?

注意,implausibleMan 是一个带有额外 superpowers 属性的 Person,而 randomPerson 是一个带有不知道什么额外属性的 Person。你无法说对一个 Person 使用 Object.keys() 将只产生已知键的数组。这就是为什么此类功能请求一直被拒绝的主要原因。
第二个问题与键的顺序有关。即使您知道正在处理包含接口中声明的所有且仅有的属性的确切类型,也不能保证 Object.keys() 返回的键与接口中的顺序相同。例如:
const personOne: Person = { name: "Nadia", age: 35 };
const personTwo: Person = { age: 53, name: "Aidan" };
Object.keys(personOne); // what's this?
Object.keys(personTwo); // what's this?

大多数合理的JS引擎可能会按照插入顺序返回属性,但你不能依赖这一点。而且,你肯定不能指望插入顺序与TypeScript接口属性顺序相同。因此,你很可能会将["age", "name"]视为类型为["name", "age"]的对象,这可能不是一个好主意。

话虽如此,我喜欢玩弄类型系统,因此我决定编写代码将联合转换为元组,类似于Matt McCutchen回答的另一个问题的方式。但这也充满了危险,我建议不要这样做。以下是注意事项。代码如下:

// add an element to the end of a tuple
type Push<L extends any[], T> =
  ((r: any, ...x: L) => void) extends ((...x: infer L2) => void) ?
  { [K in keyof L2]-?: K extends keyof L ? L[K] : T } : never

// convert a union to an intersection: X | Y | Z ==> X & Y & Z
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// convert a union to an overloaded function X | Y ==> ((x: X)=>void) & ((y:Y)=>void)     
type UnionToOvlds<U> = UnionToIntersection<U extends any ? (f: U) => void : never>;

// convert a union to a tuple X | Y => [X, Y]
// a union of too many elements will become an array instead
type UnionToTuple<U> = UTT0<U> extends infer T ? T extends any[] ?
  Exclude<U, T[number]> extends never ? T : U[] : never : never

// each type function below pulls the last element off the union and 
// pushes it onto the list it builds
type UTT0<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTT1<Exclude<U, A>>, A> : []
type UTT1<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTT2<Exclude<U, A>>, A> : []
type UTT2<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTT3<Exclude<U, A>>, A> : []
type UTT3<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTT4<Exclude<U, A>>, A> : []
type UTT4<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTT5<Exclude<U, A>>, A> : []
type UTT5<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? Push<UTTX<Exclude<U, A>>, A> : []
type UTTX<U> = []; // bail out

让我们试试:

type Test = UnionToTuple<keyof Person>;  // ["name", "age"]

看起来它可以工作。

需要注意的是,您无法以编程方式处理任意大小的联合类型。TypeScript不允许您迭代联合类型,因此此处的任何解决方案都将选择一些最大联合大小(例如六个成分),并处理直至该大小的联合。上面有点迂回的代码是设计成您可以通过复制和粘贴来扩展这个最大值。

另一个需要注意的是:它取决于编译器能够按顺序分析条件类型中的重载函数签名,并且取决于编译器能够将联合转换为重载函数并保留顺序。这两种行为都不一定保证始终以相同的方式工作,因此每次新版本的TypeScript发布时都需要进行检查。

最后一个要注意的是:它尚未经过多少测试,因此即使在保持TypeScript版本不变的情况下,它可能充满各种有趣的陷阱。如果您真的想使用此类代码,则需要在考虑在生产代码中使用之前对其进行大量测试。


总之,不要按照我展示的做任何事情。好了,希望能帮到你。祝你好运!


我了解所有的风险和“保证”方面的排序问题。我的用例不仅更深入,而且不想在这里详细说明,因为这并不重要 :) 问题实际上是如何从联合获取元组 :) 虽然感谢您详细的答案,但一些初学者会发现它非常有用。干杯 - hotell
1
仅供参考:“大多数合理的JS引擎可能会按照插入的顺序返回属性,但您不能指望这一点。”是的,您可以,前提是没有任何属性名称符合整数索引的定义。自ES2015以来,某些操作已经指定了这一点,并且自ES2017或ES2018以来也适用于Object.keys(但这只是编码所有主要引擎已经按顺序提供数组的事实)。您可能不应该这样做,但您可以。 :-) - T.J. Crowder

17

因为第一版和更新版的反馈,如果你不关心反转,可以将版本一简化为以下内容...

并且可以处理对象而不仅仅是对象键或任何其他类型。

享受吧。

/* helpers */
type Overwrite<T, S extends any> = { [P in keyof T]: S[P] };
type TupleUnshift<T extends any[], X> = T extends any ? ((x: X, ...t: T) => void) extends (...t: infer R) => void ? R : never : never;
type TuplePush<T extends any[], X> = T extends any ? Overwrite<TupleUnshift<T, any>, T & { [x: string]: X }> : never;
type UnionToIntersection<U> =(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type UnionToOvlds<U> = UnionToIntersection<U extends any ? (f: U) => void : never>;
type PopUnion<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? A : never;
/* end helpers */
/* main work */
type UnionToTupleRecursively<T extends any[], U> = {
    1: T;
    0: PopUnion<U> extends infer SELF ? UnionToTupleRecursively<TuplePush<T, SELF>, Exclude<U, SELF>> : never;
}[[U] extends [never] ? 1 : 0]
/* end main work */

type UnionToTuple<U> = UnionToTupleRecursively<[], U>;

type LongerUnion = { name: "shanon" } | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
    | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18

declare const TestType: UnionToTuple<LongerUnion> // [18, 17, 16, 15, 14....]

使用最新版本的typescript进行编辑,有一个更新更现代的版本,这个新版本依赖于未来极不可能发生变化的行为;而旧版实现则依赖于可能会改变的行为。

type UnionToIntersection<U> = (
  U extends never ? never : (arg: U) => never
) extends (arg: infer I) => void
  ? I
  : never;

type UnionToTuple<T> = UnionToIntersection<
  T extends never ? never : (t: T) => T
> extends (_: never) => infer W
  ? [...UnionToTuple<Exclude<T, W>>, W]
  : [];


  type test = UnionToTuple<"1" | 10 | {name: "shanon"}>
  // ["1", 10, {name: "shanon"}]

它在版本之间出了问题吗?它只显示 const TestType: [x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never, x: never] - kungfooman
1
嗨,Kung,我添加了一个带有示例和更新的更简单语法的编辑。 - Shanon Jackson
谢谢您的快速编辑,从这些非平凡的解决方案中学习函数式编程真是太好了。 - kungfooman
天啊,这太不可思议了。 - Ashitaka

11

@jcalz 非常令人印象深刻,阅读了您的实现后,我决定编写自己的“N”递归键版本,它可以无限深度,并保持联合的顺序。例如,“A”|“B”变成["A",“B”]

// add an element to the end of a tuple
type Push<L extends any[], T> =
  ((r: any, ...x: L) => void) extends ((...x: infer L2) => void) ?
    { [K in keyof L2]-?: K extends keyof L ? L[K] : T } : never

export type Prepend<Tuple extends any[], Addend> = ((_: Addend, ..._1: Tuple) => any) extends ((
    ..._: infer Result
) => any)
    ? Result
    : never;
//
export type Reverse<Tuple extends any[], Prefix extends any[] = []> = {
    0: Prefix;
    1: ((..._: Tuple) => any) extends ((_: infer First, ..._1: infer Next) => any)
        ? Reverse<Next, Prepend<Prefix, First>>
        : never;
}[Tuple extends [any, ...any[]] ? 1 : 0];



// convert a union to an intersection: X | Y | Z ==> X & Y & Z
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// convert a union to an overloaded function X | Y ==> ((x: X)=>void) & ((y:Y)=>void)     
type UnionToOvlds<U> = UnionToIntersection<U extends any ? (f: U) => void : never>;

// returns true if the type is a union otherwise false
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

// takes last from union
type PopUnion<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? A : never;

// takes random key from object
type PluckFirst<T extends object> = PopUnion<keyof T> extends infer SELF ? SELF extends keyof T ? T[SELF] : never;
type ObjectTuple<T, RES extends any[]> = IsUnion<keyof T> extends true ? {
    [K in keyof T]: ObjectTuple<Record<Exclude<keyof T, K>, never>, Push<RES, K>> extends any[]
        ? ObjectTuple<Record<Exclude<keyof T, K>, never>, Push<RES, K>>
        : PluckFirst<ObjectTuple<Record<Exclude<keyof T, K>, never>, Push<RES, K>>>
} : Push<RES, keyof T>;

/** END IMPLEMENTATION  */



type TupleOf<T extends string> = Reverse<PluckFirst<ObjectTuple<Record<T, never>, []>>>

interface Person {
    firstName: string;
    lastName: string;
    dob: Date;
    hasCats: false;
}
type Test = TupleOf<keyof Person> // ["firstName", "lastName", "dob", "hasCats"]

哇!Reverse<>太疯狂了!我曾经认为在TypeScript中递归类型是不可能的。我怀疑在这里使用核心机制,可以归纳地操作任意长度的元组,可以用来构建许多以前在这种语言中无法访问的代数类型。我想知道它是否提供了完整的解决方案来弥补缺失的可变参数类型特性?我现在看到它的工作方式是基于infer遵循延迟类型分辨率,而不是急切的:https://raw.githubusercontent.com/unional/typescript-guidelines/master/pages/advance-types/recursive-types.md - David V McKay
请勿在非常大的联合体上使用TupleOf<>,例如FontAwesome中所有图标的名称 - 这将使您的编译器/ linter / ide的语言服务挂起很长时间,而您的CPU则会冲泡咖啡。 - David V McKay
似乎在联合中约有7或8个元素时会出现问题。可能明确枚举各种深度更快/更实用。:( - David V McKay
1
我怀疑如果你牺牲顺序,应该能够更深入地进行,但事实上,在递归和重载之间,编译器工作没有区别(我相信),这只取决于这个解决方案有更多的循环。我认为我可以写一个更高效的解决方案。 - Shanon Jackson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接