首页 > 解决方案 > TypeScript:是否可以安全地访问给定键数组的对象的嵌套属性?这可以以类型安全和可组合的方式完成吗?

问题描述

我想编写一个函数,它从给定属性键数组的对象中获取值。它看起来像这样:

function getValue<O, K extends ObjKeys<O>>(obj: O, keys: K): ObjVal<O,K> {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}

我希望这个功能像这样工作:

type Foo = {
  a: number
  b: string
  c: {
    lark: boolean
    wibble: string
  }
}

let o: Foo = {
  a: 1,
  b: "hi",
  c: {
    lark: true,
    wibble: "there"
  }
}

// I'd like these to type check (and return the expected values):
getValue(o, ['a']) // returns 1
getValue(o, ['b']) // returns "hi"
getValue(o, ['c','lark']) // returns true

// I'd like these _not_ to type check:
getValue(o, ['a','b'])
getValue(o, ['d'])

重要的是,我希望有一个ObjKeys<O>可用的类型(如上面的示例),以便我可以在其他函数中轻松使用此函数,同时保留输入。例如,我可能想做这样的事情:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ObjKeys<O>) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

这个函数需要一些键并将它们传递给我们getValue上面的函数,理想情况下它会进行类型检查,因为对象O和键ObjKeys<O>都是在其中getValue调用的函数的有效参数。

getValue这扩展到返回由;返回的值。我可能还想做这样的事情:

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ObjKeys<O>): ObjVal<O> {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

这也使用了类似ObjVal<O>知道返回类型是什么的东西,因此完全类型检查。

有没有解决方案,或者根本没有办法在 TypeScript 中做这种事情(撰写本文时的版本 4)?

到目前为止我最好的:

我可以定义一个允许嵌套访问的函数,如下所示:

function getValue<
    O  extends object, 
    K1 extends keyof O
>(obj: O, keys: [K1]): O[K1]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1]
>(obj: O, keys: [K1,K2]): O[K1][K2]
function getValue<
    O  extends object, 
    K1 extends keyof O, 
    K2 extends keyof O[K1], 
    K3 extends keyof O[K1][K2]
>(obj: O, keys: [K1,K2,K3]): O[K1][K2][K3]
function getValue<O>(obj: O, keys: Key | (Key[])): unknown {
  let out = obj;
  for (const k in keys) out = out[k]
  return out
}
type Key = string | number | symbol

然后当我尝试访问值时,这个类型检查正确(在这种情况下最多 3 层)。

但是,当我想在保持类型安全的同时使用另一个函数时,我有点卡住了:

function areValuesEqual<O>(obj: O, oldObj: O, keys: ????) {
  let value = getValue(obj, keys)
  let oldValue = getValue(oldObj, keys)
 
  return value === oldValue ? true : false 
}

function doSomethingAndThenGetValue<O>(obj: O, oldObj: O, keys: ????): ???? {
  let value = getValue(obj, keys)
  console.log("Value obtained is:", value)
  return value
}

我不确定我可以用什么????来告诉 TypeScript 类型如何相互关联,以便进行类型检查。每次我想编写像上面这样的函数时,我是否可以避免重写我的重载列表,但仍然可以获得我想要的类型检查?

标签: typescripttypescript-generics

解决方案


这已经接近我可以从类型系统中获得的极限了。TypeScript 4.1 将支持递归条件类型,但即使使用它们,我想你也很可能会遇到循环错误、“类型实例化太深”错误,或其他任何试图getValue()通用使用的奇怪错误。所以我不确定我是否真的建议你使用我将在下面写的内容:


另一个问题中,我写了如何说服编译器给你一个对象的所有有效键路径的联合,表示为一个元组。它看起来像这样:

type Cons<H, T> = T extends readonly any[] ? [H, ...T] : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: [K] | (Paths<T[K], Prev[D]> extends infer P ?
        P extends [] ? never : Cons<K, P> : never
    ) }[keyof T]
    : [];

您可以验证

type FooPaths = Paths<Foo>;
// type FooPaths = ["a"] | ["b"] | ["c"] | ["c", "lark"] | ["c", "wibble"]

以下定义DeepIdx<T, KS>将给出键路径上的属性类型KS(其中KS extends Paths<T>应该为真),但仅适用于 TS4.1+:

type DeepIdx<T, KS extends readonly any[]> = KS extends readonly [infer K, ...infer KK] ?
    K extends keyof T ? DeepIdx<T[K], KK> : never : T

您可以验证

type FooCWibble = DeepIdx<Foo, ["c", "wibble"]>;
// type FooCWibble = string

有了这些,您getValue()可以像这样输入而不会重载:

function getValue<O, KK extends Paths<O> | []>(
    obj: O, keys: KK
): DeepIdx<O, KK> {
    let out: any = obj;
    for (const k in keys) out = out[k as any]
    return out;
}

您可以验证这些工作:

const num = getValue(o, ['a']) // number
const str = getValue(o, ['b']) // string 
const boo = getValue(o, ['c', 'lark']) // boolean

getValue(o, ['a', 'b']) // error!
// -------------> ~~~
// b is not assignable to lark | wibble
getValue(o, ['d']) // error!
// --------> ~~~
// d is not assignable to a | b | c

areValuesEqual()如果您给出keys类型,则此定义也适用于内部Paths<O>

function areValuesEqual<O>(obj: O, oldObj: O, keys: Paths<O>) {
    let value = getValue(obj, keys)
    let oldValue = getValue(oldObj, keys)
    return value === oldValue ? true : false
}

因为doSomethingAndThenGetValue()你必须使keys通用,以便编译器知道会发生什么:

function doSomethingAndThenGetValue<O, K extends Paths<O>>(
  obj: O, 
  oldObj: O, 
  keys: K
): DeepIdx<O, K> {
    let value = getValue(obj, keys)
    console.log("Value obtained is:", value)
    return value
}

我可以准确地解释所有这些类型是如何工作的,但它有点复杂,并且有一些专门用于哄骗类型推断的构造以正确的方式工作(| []元组上下文的提示)或避免立即循环警告(Prev元组是在那里放置一个最大递归深度的调控器),我不知道详细解释一些我会严重犹豫放入任何生产代码库的东西有多大用处。

出于您的目的,您可能只想在运行时更多地执行约束并执行类似PropertyKey[]for 的操作keys。在 or 的实现中,areValuesEqual()您可以使用any[]or 类型断言来使编译器接受它。


Playground 代码链接


推荐阅读