【问题标题】:Generic function to get a nested object value获取嵌套对象值的通用函数
【发布时间】:2021-02-10 23:58:23
【问题描述】:

问题本身比这更复杂,但我会尽量用简单的方式解释。

我有一个对象(它可能是一个不同的对象,比如说汽车或用户或其他什么)。

const car = {
  model: 'Model A',
  specs: {
    motor: {
      turbo: true
    }
  }
};

然后我有一个从属性名称中获取值的函数。

interface Data<T, K extends keyof T> {
  keynames: string[];
  values: Array<T[K] | null>;
}

const getValues = <T, K extends keyof T>(keynames: string[], data: T): Data<T, K> => {
  const values: Array<T[K] | null> = [];

  keynames.forEach(keyname => {
    const value: T[K] | undefined = getObjectValue(data, keyname);
    if (typeof value === 'undefined') {
      values.push(null);
    } else {
      values.push(value);
    }
  });

  return {
    keynames,
    values
  }
};

// This works with key.name syntax, specs.motor.turbo returns true for example
const getObjectValue = <T, K extends keyof T>(
  object: T,
  keyName: string
): T[K] | undefined => {
  const keys = keyName.split('.');
  if (keys.length === 1) {
    if (typeof object === 'object') {
      if (keys[0] in (object as T)) {
        return (object as T)[keys[0] as K];
      }
      return undefined;
    }
    return undefined;
  } else {
    const [parentKey, ...restElements] = keys;
    if (!object) return undefined;
    return (getObjectValue(
      (object as T)[parentKey as K],
      restElements.join('.')
    ) as unknown) as T[K];
  }
};

例如,当我编写一些测试时,问题就变成了:

// ...
const data = getValues(['model', 'specs.motor.turbo'], car);
assert.deepEqual(data, {
  keynames: ['model', 'specs.motor.turbo'],
  values: ['Model A', true],
});
// ...

data 是我所期望的,我的函数返回与预期相同的对象,但出现错误:

Type 'boolean' is not assignable to type 'string | { motor: { turbo: boolean; }; }'.ts(2322)

这很明显,因为我只是在函数中推断 T[K] 而不是嵌套属性的类型。

如何实现这一点,所以我的函数适用于返回类型string | { motor: { turbo: boolean; }; } | { turbo: boolean; } | boolean。或者也许有一种更简单的方法来返回嵌套的属性值。

【问题讨论】:

  • 这个问题并不完全是 this one 的重复,但它取决于它,因为将对象路径表示为虚线字符串是先决条件。
  • 现在我在玩它,使用像LeavesPaths 这样的东西对编译器来说真的很困难,并且会导致巨大的性能问题;我想我会走另一条路……

标签: node.js typescript


【解决方案1】:

我将尽可能地保留您的实现,即使有人会提出一些改进建议。我主要将此问题视为“我如何告诉 TypeScript 编译器 getValues() 在做什么?”。

首先,我们需要表示当您使用虚线键路径 K 深入索引类型 T 时会出现什么。这只能在 TypeScript 4.1 及更高版本中实现,因为以下实现既依赖于在 microsoft/TypeScript#40336 中实现的 模板文字类型,又依赖于实现的 递归条件类型microsoft/TypeScript#40002:

type DeepIndex<T, K extends string> = T extends object ? (
  string extends K ? never :
  K extends keyof T ? T[K] :
  K extends `${infer F}.${infer R}` ? (F extends keyof T ?
    DeepIndex<T[F], R> : never
  ) : never
) : never

这里的总体方案是检查K是否是T的key;如果是这样,我们使用T[K] 进行查找。否则,我们将第一个点处的K 拆分为FR,然后向下递归,使用键R 索引到T[F]。如果在任何时候出现问题(例如F 不是T 的有效键),这将返回never 而不是属性类型。我们将使用它;如果某事变成了never,那么我们假设有一个错误的索引:

type ValidatePath<T, K> =
  K extends string ? DeepIndex<T, K> extends never ? never : K : never;

ValidatePath&lt;T, K&gt; 类型将从(可能的键联合)K 中仅提取有效路径的成员。


在我们开始之前,让我们也代表你将undefined替换为null时所做的事情:

type UndefinedToNull<T> = T extends undefined ? null : T;

对于您的代码,我将继续使用tuple 类型来表示Data 中的keynamesvalues 数组。如有必要,这应该回退到无序数组,但是丢弃您知道的信息似乎很奇怪;例如,在您的car 示例中,您知道values 数组的第一个元素是string,第二个元素是boolean。像[string, boolean] 这样的类型比Array&lt;string | boolean | null&gt; 有更多的信息:

interface Data<T, K extends string[]> {
  keynames: K;
  values: { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
}

values 属性使用mapped array/tupleK 中的键名元组转换为深度索引值元组。

现在我们需要为您的函数签名提供强类型。编译器将无法验证实现中的很多类型安全,所以我将使用as 做很多type assertions 来强制编译器接受我们正在做的事情:

const getValues = <T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T): Data<T, K> => {

  const values = [] as { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
  const _keynames = keynames as K;
  _keynames.forEach(<I extends number>(keyname: K[I]) => {
    const value: DeepIndex<T, Extract<K[I], string>> | undefined = getObjectValue(data, keyname as any);
    if (typeof value === 'undefined') {
      values.push(null as UndefinedToNull<DeepIndex<T, K[I]>>);
    } else {
      values.push(value as UndefinedToNull<DeepIndex<T, K[I]>>);
    }
  });

  return {
    keynames: _keynames,
    values
  }
};

const getObjectValue = <T, K extends string>(
  object: T,
  keyName: K & ValidatePath<T, K>
): DeepIndex<T, K> | undefined => {
  const keys = keyName.split('.');
  if (keys.length === 1) {
    if (typeof object === 'object') {
      if (keys[0] in (object as T)) {
        return (object as T)[keys[0] as keyof T] as DeepIndex<T, K>;
      }
      return undefined;
    }
    return undefined;
  } else {
    const [parentKey, ...restElements] = keys;
    if (!object) return undefined;
    return (getObjectValue(
      (object as T)[parentKey as keyof T],
      restElements.join('.') as any as never
    ) as unknown) as DeepIndex<T, K>;
  }
};

我不会太担心实现中的断言。这里重要的部分是getValues()的调用签名:

<T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T) => Data<T, K>

我们将keynames 解释为可分配给string 值数组的东西。最后的| [] 只是暗示编译器应该更喜欢将['model', 'specs.motor.turbo'] 作为有序对而不是无序数组查看。与映射元组{[I in keyof K]: ValidatePath&lt;T, K[I]&gt;} 的交集应该是空操作,因为K 的所有元素都是到T 的有效路径。但是,如果 K 的任何元素是无效路径,则映射元组的该元素将是 never 并且验证将失败。


让我们测试一下:

const car = {
  model: 'Model A',
  specs: {
    motor: {
      turbo: true
    }
  }
}

const data = getValues(['model', 'specs.motor.turbo'], car);
data.keynames; // ["model", "specs.motor.turbo"]
data.values; //  [string, boolean]
console.log(data);

这看起来正是您想要的示例。如果我们拼错一个键会发生什么?

getValues(['model', 'specs.motar.turbo'], car); // error!
// ---------------> ~~~~~~~~~~~~~~~~~~~
// Type 'string' is not assignable to type 'undefined' ?‍♂️

我们在有问题的键上得到一个错误。不幸的是,错误消息不是那么有用。当我尝试使用this question 的答案为编译器提供一个完整的列表,其中列出了要接受的 哪些 虚线路径,这导致速度大幅下降,甚至出现一些“类型实例化过深或无限”错误。所以虽然很高兴看到"specs.motar.turbo" is not assignable to "model" | “规格” | “规格.电机” | “specs.motor.turbo”`,遗憾的是我无法以合理的方式实现这一点。

如果相关属性可能存在undefined 值怎么办?

const d2 = getValues(['a.b'], { a: Math.random() < 0.5 ? {} : { b: "hello" } });
d2.keynames; // ["a.b"]
d2.values; // [string | null]
console.log(d2);

它显示为| null 而不是| undefined,这很好。


这样我就可以做到了。 毫无疑问存在限制和极端情况。使用虚线键进行深度索引有点接近 TypeScript 中工作的边缘(在 4.1 之前明显超过边缘,所以这是什么,对吗?)。例如,我预计对象树的非叶节点上的可选或联合类型属性会发生奇怪/坏事。或者具有字符串索引签名的对象,就此而言。这些可能是可以解决的,但需要一些努力和大量测试。重点是:小心行事。


Playground link to code

【讨论】:

  • 谢谢,我已经在那个问题上看到了你的回答。我在适应我的代码时遇到了一些问题,但我认为我能够做到。我还查看了this 并尝试将其调整为我的代码,但没有运气。
猜你喜欢
  • 2021-11-09
  • 1970-01-01
  • 2021-11-30
  • 2018-10-14
  • 2018-10-24
  • 2013-10-11
  • 2021-11-29
  • 2020-07-03
  • 1970-01-01
相关资源
最近更新 更多