Typescript:基于嵌套键数组返回类型

t9eec4r0  于 2023-05-30  发布在  TypeScript
关注(0)|答案(1)|浏览(129)

我正在为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向导愿意帮助我解决这个“挑战”?
非常感谢你,如果你试图解决这个问题!

cbeh67ev

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>。这必须是一个递归定义的类型。
这里有一个可能的方法:

type DeepRequired<T, K extends string> =
  T extends readonly any[] ? { [I in keyof T]-?: DeepRequired<T[I], K> } :
  T extends object ?
  (T & {
    [P in K extends `${infer K0}.${string}` ? K0 : K]-?:
    DeepRequired<P extends keyof T ? T[P] : {}, K extends `${P}.${infer KR}` ? KR : never>
  }) :
  T extends undefined ? never : T;

首先,当一个数组类型存在于路径的任何地方时,有一个复杂的问题,你想“跳过”它。毕竟,{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>,它通过一个类型向下递归,并将数组的交集转换为数组的交集,如下所示:

type DeepMerge<T> =
  T extends readonly (infer E)[] ? DeepMerge<E>[] :
  T extends object ? { [K in keyof T]: DeepMerge<T[K]> } : T;

如果T是一个数组,那么DeepMerge<T>就变成了一个元素类型为T的数组,这样就解决了任何交集问题。否则,我们向下递归DeepMerge
所以retrieve()应该看起来像:

declare const retrieve: <K extends string>(
    { include }: { include: K[] }) => DeepMerge<DeepRequired<Cart, K>>;

让我们来测试一下:

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; } // same as Product 🤷‍♂️   

const cart = retrieve({ include: [] });
/* const cart: {
    id: number;
    items?: {
        id: number;
        product?: { id: number; title: string; } | undefined;
        brand?: { id: number; title: string; } | undefined;
    }[] | undefined;
} */

根据需要,该类型等效于Cart。它不是这样写的,因为编译器需要递归到它,以确保它没有隐藏在那里的数组的交集。现在让我们看看我们来这里的行为:

const cart_with_items = retrieve({
  include: ["items"]
});

/* const cart_with_items: {
    id: number;
    items: {
        id: number;
        product?: { id: number; title: string; } | undefined;
        brand?: { id: number; title: string; } | undefined;
    }[];
} */

cart_with_items.items.forEach((item) => {
  console.log(item.brand.title) // error, item.brand is possibly undefined
})

看起来也不错。现在需要items属性。让我们继续:

const cart_with_items_and_product_and_brand = retrieve({
  include: ["items.brand", "items.product"]
})

/* const cart_with_items_and_product_and_brand: {
    id: number;
    items: {
        id: number;
        product: { id: number; title: string; };
        brand: { id: number; title: string; };
    }[];
} */
    
cart_with_items_and_product_and_brand.items.forEach((item) => {
  console.log(item.brand.title)
  console.log(item.product.title)
}); // okay

这也很好需要items属性,以及items数组元素的productbrand属性。
这是一种方法。完美吗?几乎可以肯定不是。像这样的深度递归类型往往有奇怪的边缘情况。你可能会发现在某些情况下它会产生一个完全出乎意料的结果。不幸的是,有时在保持“好”部分工作的同时修复这些结果需要完全重构,其中设计类型的一些关键假设被证明是无效的。我所能建议的是,在生产代码库中使用任何此类类型之前,针对您期望看到的用例进行彻底的测试。
Playground链接到代码

相关问题