如何将变量从字符串缩小到具有可区分联合的大型对象中嵌套对象的键

How to narrow a variable from a string to a key of a nested object in a large object with Discriminated unions

提问人:QuantumNoisemaker 提问时间:4/26/2023 最后编辑:QuantumNoisemaker 更新时间:4/26/2023 访问量:103

问:

我正在打字稿中使用双嵌套的 JSON 对象,当我尝试将字符串缩小到第一个嵌套字符串的键时,我的头一直在撞墙。将字符串缩小到主对象的键是微不足道的,如果只有几个嵌套对象,则可以使用 if-else 或 switch 可靠地处理。但是,大约有 40 个第 1 层嵌套对象,每个对象都有区分联合。我可以为每个特定对象设计一个很长的开关测试用例,但这会很乏味,并且可能最终会重复大量代码,所以我想找到一种更优雅的方法。

以下是该对象的示例:

const jsonObject = {
  cow: {
    milk: {
      chemicalState: "liquid",
      value: 10,
      description: "for putting on cereal"
    },
    cheese: {
      chemicalState: "solid",
      value: 25,
      description: "for putting on sandwiches"
    },
  },
  pig: {
    bacon: {
      chemicalState: "solid",
      value: 100,
      description: "for putting on everything"
    },
    pork: {
      chemicalState: "solid",
      value: 50,
      description: "for dinner"
    },
  },
  //... and thirty more double nested objects from here
}

在这个例子中,每只动物将有一组完全不同的产品,彼此之间没有重叠。目标是编写一个函数,该函数可以在给定动物和产品的情况下从 jsonObject 中提取描述条目:

const getDescription = (animal: string, product: string):string => {
  return jsonObject[animal][product].description
}

使用用户定义的类型保护来缩小第一个键(动物)的范围是微不足道的。但是,jsonObject[animal] 不会缩小到一个特定的产品对象,而是所有可能的产品对象的联合(前面提到的可区分联合)。在计算 jsonObject[animal] 之后,只使用了一种 animalProduct 对象类型,但 typescript 编译器无法知道是哪种类型,因此它不是返回单个 animalProduct 对象,而是重新合并了 32 个可能的 animalProducts,这在开关中需要处理很多。我现在也试图避免使用“as”中的保释金。

理想情况下,有一种方法可以编写一个通用/泛型用户定义的类型保护,以缩小第二个键的范围,就像第一个键一样;一个只允许所选动物的特定键通过(因此对于 jsonObject[cow],键类型应为 milk|cheese)。然而,这可能是不可能的或不切实际的,所以欢迎任何和所有的建议和技巧(对 Typescript 和学习来龙去脉仍然很陌生)!


我做了很多尝试来缩小两个键的范围,然后是先是可区分的并集,然后是键。我最接近的尝试可能是使用这个用户定义的泛型(受到这篇文章的启发,不幸的是它没有解决我的问题,因为它并没有消除对大量 switch 语句的需求):

const isAnimalProduct = <T extends string>(s:string, o: Record<T,any>): s is T => {
  return s in o;
}

这很接近,但会给你输入 Record<“milk” |“奶酪” |“培根” |“猪肉”,任何>而不是 Record<“牛奶” |“奶酪”,任何> |唱片<“培根” |“猪肉”,任何>,似乎并没有在必要时缩小范围,但我仍然承认我的头在缠绕它。

下面是 Playground 链接,其中包含代码的完整模型和更多尝试。


我还在相关线程上查看此响应和此响应,但我无法获得类似的结果,因为两者都使用硬编码值,并且在我的情况下,这两个值都是变量,并且您最终仍然会得到一个可区分的并集(但如果我弄错了,请告诉我)。

另外,据我所知,这个问题与 JSON 无关,因为我设置了“resolveJsonModule”: true,并且打字稿似乎在导入它时没有问题。从游乐场链接中可以看出,即使使用纯对象,问题仍然存在。


编辑:输入和 getDescription 来自用户输入(特别是两个组合框)。我已将产品的第二个组合框配置为仅接受基于组合框状态的正确输入(如果用户在第一个组合框中选择奶牛,则第二个组合框将仅显示牛奶和奶酪)。但是,我试图防止的行为是当用户返回并更改第一个组合框(即更改为 pig)时,导致键不匹配(例如 pig、milk)。animalproductanimalanimal

JCalz 建议我

将 jsonObject 扩大为双重嵌套的索引签名,并测试是否为 undefined。

我的理解是,这应该防止这种情况,因为如果将 Pig 和 Milk 提供给索引签名,这将被正确地检测为未定义。我现在将完全实现它以确认!

具有更新上下文的新 Playground 链接

打字稿 typescript-generics 可区分联合 嵌套对象 类型缩小

评论

0赞 jcalz 4/26/2023
(1) 为什么和?其中一个是错别字吗?(2)鉴于你的数据,我想说你真的不想这样折磨自己,除非你真的关心特定的密钥名称。我只是扩大到一个双重嵌套的索引签名并测试,如下所示。如果这不能满足您的需求,请编辑代码示例以说明原因。如果是这样,我可以写一个答案来解释。itemchemicalStatejsonObjectundefined
0赞 QuantumNoisemaker 4/26/2023
感谢 Jcalz 的帮助!(1)是的,我试图想出一个简单的例子,这在过渡中被留下了。修好了!(2)“我想说你真的真的不想这样折磨自己”——我毕竟是个受虐狂。老实说,我一直在强迫自己尽可能精确地强迫自己学习打字稿,而不是无处不在地使用。“just widen jsonObject” - 我没有考虑过这一点,并相信在这种情况下这是一个解决方案。我会在我的问题中添加更多内容,以确认我正确理解了答案。抱歉,按回车键而不是 shift-enter。
0赞 jcalz 4/26/2023
所以。。。这是否意味着我应该写下我的建议作为答案?
0赞 jcalz 4/26/2023
🙃 好的,呃,我现在看到您之前的评论已被编辑,并且您正在编辑问题以...谈谈我的建议,也许有效?当你有机会时,请发表评论,让我知道你是否希望我写一个答案来解释我的建议🤓📝,或者我是否应该尖叫🏃 ♂️😱着逃跑。无论哪种方式对我来说都很好。
1赞 QuantumNoisemaker 4/26/2023
对不起哈哈哈!是的,我在输入较早的评论时按回车而不是 shift-enter,并且比我想要哈哈更早发布它(尽管评论无论如何根本不关心 /n,所以我有祸了)。我编辑了我的问题,以提供更多类型保护背后的动机,以及我为什么相信你的答案解决了我的问题(似乎也适用于我的完整代码库)。请告诉我,如果我的理解是正确的,或者随意尖叫着逃跑,在这一点上我会完全理解的!🤣

答:

1赞 jcalz 4/26/2023 #1

编译器在跟踪相关联合类型方面不是很好(请参阅 microsoft/TypeScript#30581),因此您的原始方法只会给您带来麻烦。通常,最好用泛型而不是联合来写东西,但在你的例子中,结构可以用索引签名更简单地表示:jsonObject

const j: Dictionary<Dictionary<Product>> = jsonObject;

interface Dictionary<T> {
  [k: string]: T | undefined
};

interface Product {
  chemicalState: string;
  value: number;
  description: string;
}

我已将(我假设它来自外部导入的文件,因此您无法更改其类型)重新分配给 ,其类型为 .该赋值成功,因此编译器认为它们是兼容的。A 只是一个具有未知键的对象类型,其值为 either 或 undefined。因此,类型是具有未知键的对象,其值要么是未定义的,要么是另一个具有未知键的对象,其值要么是未定义的,要么是上面定义的。您现在所要做的就是检查何时索引到 ,如下所示:jsonObjectjDictionary<Dictionary<Product>>Dictionary<T>TjProductundefinedj

const getDescription = (animal: string, product: string): string => {
  return j[animal]?.[product]?.description ?? "not valid";
}

这是使用可选的 chaining (?.) 运算符和 nullish 合并 (?) 运算符来处理 s。如果存在,则返回。否则,将返回 (而不是索引错误),并且 ,又名。undefinedj[animal][product].descriptiongetDescriptionj[animal]?.[product]?.descriptionundefinedundefined ?? "not valid""not valid"

Playground 代码链接

评论

0赞 QuantumNoisemaker 4/27/2023
超级清晰的答案和写作,非常感谢 JCalz,它现在就像一个魅力!一个后续.在我的例子中,JSON对象被导入到文件中,但它是本地存储的,并且是不可变的;JSON实际上是从谷歌表格电子表格中提取的,然后程序将其用作本地数据库,将用户输入转换为数值。既然我可以完全控制 JSON 对象的形式,那么有没有更好的方法来格式化它,或者有更好的方法将其与打字稿一起使用?jsonObject (which I assume came from an external imported file so you can't change its type)
0赞 jcalz 4/27/2023
在我确定之前,我需要看到导入它的代码,但我认为这种方法没有任何不好的地方。
0赞 QuantumNoisemaker 4/27/2023
太棒了,代码只是:例如,在 tsconfig 中使用。似乎效果很好,所以我决定使用它。import lvls from './json/CharLvl.json'"resolveJsonModule": true,
0赞 jcalz 4/27/2023
我想说你应该保持原样。有一些方法可以告诉编译器将导入的 json 视为特定类型,但实际上这样做的类型安全性较低。你可以写,然后(或任何正确的类型),然后你可以继续使用,但现在它有一个更有用的类型。import _lvls from './json/CharLvl1.json'const lvls: Dictionary<Dictionary<Product>> = _lvlslvls
0赞 QuantumNoisemaker 4/29/2023
太好了,这也符合我的想法。我实际上不得不对我导入的一个 JSON 文件执行此操作,因为它包含许多可选值(即某些对象具有其他对象没有的值),并且编译器似乎无法正确处理这些值,而无需将 JSON 显式转换为带有可选的对象类型(尽管我也可以重新编写该代码以使用您上面的建议, 而不是在全球范围内重新铸造它)。我会牢记这两种选择。再次感谢您对JCalz的帮助,真的帮助我澄清了最佳实践。