我正在为REST API创建一个Typescript SDK,并且正在努力使它像我希望的那样类型安全。SDK公开了一个“retrieve”函数,该函数接受一个参数“include”来用附加数据扩展响应。例如,一个用户可能想要检索包含“物品”的“购物车”实体以避免做出两个请求。
给定以下类型和“检索”函数:
// Types
type Cart = {
id: number;
// ...
items?: CartItem[]
}
type CartItem = {
id: number;
// ...
product?: Product;
brand?: Brand;
};
type Product = {
id: number;
title: string;
};
type Brand = {
id: number;
title: string;
}
// This is the function I want to be as type-safe as possible
const retrieve = ({include: string[]}) => {
return {
id: 1,
// ...
} as any;
}
我想取得以下成果。请注意,包含items.brand
是如何将items
中的items
属性和brand
属性标记为已定义的(可能与undefined
相反)。
/* const cart: {
id: number;
} */
const cart: Cart = retrieve({ })
/* const cart_with_items: {
id: number;
items: {
id: number;
}[];
} */
const cart_with_items: Cart & { items: CartItem[] } = retrieve({
include: ["items"]
});
/* const cart_with_items_and_product_and_brand: {
id: number;
items: {
id: number;
product: {
id: number;
title: string;
};
brand: {
id: number;
title: string;
};
}[];
} */
const cart_with_items_and_product_and_brand = retrieve({
include: ["items.brand", "items.product"]
})
// The following code should throw an error
cart_with_items.items.forEach((item) => {
console.log(item.brand.title) // Error: 'item.brand' is possibly 'undefined'
})
// The following code should not throw any error
cart_with_items_and_product_and_brand.items.forEach((item) => {
console.log(item.brand.title)
console.log(item.product.title)
})
我试着破解这个几个小时都没有成功,有没有哪位慷慨的Typescript向导愿意帮助我解决这个“挑战”?
非常感谢你,如果你试图解决这个问题!
1条答案
按热度按时间cbeh67ev1#
你想接受一个类型
T
,比如Cart
,并能够传递给它一个点线路径类字符串的并集,比如"items.brand" | "items.product"
,并生成一个新的类型,其中需要存在与这些字符串对应的路径上的嵌套属性(由于数组类型的原因,在某种程度上是复杂的),而不是undefined
。这看起来像一个深度键依赖的Required<T>
类型,或者像一个来自Typescript typecast object so specific required keys are no longer optional in the type?的RequireKeys<T>
,但用于点路径。我们称之为DeepRequired<T, K>
。这必须是一个递归定义的类型。这里有一个可能的方法:
首先,当一个数组类型存在于路径的任何地方时,有一个复杂的问题,你想“跳过”它。毕竟,
{x: {y: {z: string}[]}
类型的值没有"x.y.z"
属性;它具有"x.y[2].z"
或"x.y.123.z"
属性或类似属性。因此,如果T extends readonly any[] ? { [I in keyof T]-?: DeepRequired<T[I], K> } :
是一个数组,它只会“跳过”当前的T
类型,而只是Map具有相同路径K
的元素。然后,如果
T
是一个非数组对象,我们希望将它与一个依赖于K
的类型相交,我们需要解析它。解析是用template literal types完成的。{[P in K extends ⋯]-?: DeepRequired<⋯, K extends ⋯>}
是一个mapped type,其中键P
迭代K
中第一个点之前的部分,如果没有点,则迭代整个K
(所以如果K
是"a.b" | "a.c" | "d.e" | "f"
,那么我们迭代的键是"a" | "d" | "f"
)并且其中该值涉及在键P
处用DeepRequired
递归到T
的属性,其中K
的部分在第一个点之后。如果
T
不是一个对象,那么我们保留它,除非它是undefined
,它被删除了。这或多或少已经足够了,除了它会产生像
CartItem[] & (CartItem & { product: Product })[]
这样的数组的交集,这些交集的行为并不符合您的要求。请参阅microsoft/TypeScript#41874了解有关信息。为了避免这种情况,让我们定义一个DeepMerge<T>
,它通过一个类型向下递归,并将数组的交集转换为数组的交集,如下所示:如果
T
是一个数组,那么DeepMerge<T>
就变成了一个元素类型为T
的数组,这样就解决了任何交集问题。否则,我们向下递归DeepMerge
。所以
retrieve()
应该看起来像:让我们来测试一下:
根据需要,该类型等效于
Cart
。它不是这样写的,因为编译器需要递归到它,以确保它没有隐藏在那里的数组的交集。现在让我们看看我们来这里的行为:看起来也不错。现在需要
items
属性。让我们继续:这也很好需要
items
属性,以及items
数组元素的product
和brand
属性。这是一种方法。完美吗?几乎可以肯定不是。像这样的深度递归类型往往有奇怪的边缘情况。你可能会发现在某些情况下它会产生一个完全出乎意料的结果。不幸的是,有时在保持“好”部分工作的同时修复这些结果需要完全重构,其中设计类型的一些关键假设被证明是无效的。我所能建议的是,在生产代码库中使用任何此类类型之前,针对您期望看到的用例进行彻底的测试。
Playground链接到代码