如何推断深度嵌套属性的类型?

huangapple go评论104阅读模式
英文:

How to infer types of deeply nested properties?

问题

我正在尝试编写一个函数,该函数接受一个描述符对象并使用推断的类型信息返回一个具有强类型的良好API的不同对象。

问题在于,TypeScript似乎只会推断嵌套属性的类型,如果它们受到某种限制,比如联合类型。如果它们受限于原始类型(例如string),它们将保持原始类型,并且不会缩小到传入的实际文字。

在上面的示例中,inferredValuechildren属性被正确推断为元组,以及someProp属性以及子对象的type属性被缩小为我想要的特定文字类型。然而,子对象的name属性保持为string类型,我不确定为什么。

我可以通过在每个不需要缩小的值后面添加as const来解决这个问题,甚至可以在每个子对象后面添加它。但是,这不适用于比元组更低的任何嵌套级别,因为元组变为只读,然后在具有泛型的API中使用起来很困难。无论如何,我希望尽量避免这种解决方案,因为添加类型断言感觉像一种折中,而不是真正的解决方案。

最终,这些描述符对象将由我构建的另一款软件生成,所以如果必须的话,我会采用这种解决方法。我只是想看看是否有其他我没有看到的解决方法。

我工作的环境受到 TypeScript 4.7.4 的限制,因此我必须使用该版本中可用的功能。

英文:

I'm trying to write a function that takes in a descriptor object and uses the inferred type information to return a different object that has a nice API with strong typing.

The problem I'm running into is that TypeScript seems to only infer the types of nested properties if they are constrained to something like a union type. If they are constrained to a primitive type (e.g. string), they stay typed as that primitive and don't narrow to the actual literal that is passed in.

type ChildType = 'foo' | 'bar' | 'baz';

type ChildDescriptor<
    TName extends string = string,
    TType extends ChildType = ChildType,
> = {
    name: TName;
    type: TType;
};

type ParentDescriptor<
    TSomeProp extends string,
    TChildren extends ChildDescriptor[],
> = {
    someProp: TSomeProp;
    children: [...TChildren];
};

// Identity function so we can observe what types are inferred
const identity = <
    TSomeProp extends string,
    TChildren extends ChildDescriptor[],
>(
    descriptor: ParentDescriptor<TSomeProp, TChildren>,
) => descriptor;

const inferredValue = identity({
    someProp: 'I get inferred',
    children: [
        {
            name: 'I don\'t get inferred',
            type: 'foo',
        },
        {
            name: 'I don\'t get inferred either',
            type: 'baz',
        }
    ],
});

const [childA, childB] = inferredValue.children;

Playground

In the example above, the children property of inferredValue is properly inferred as a tuple, and the someProp property as well as the type property of the children objects are narrowed to the specific literal types as I want. However, the name property of the child objects is staying typed as string and I'm not sure why.

I can work around this by adding as const after each value that's not narrowing, or even add it after each child object. It doesn't work any nest level lower than that because of the tuple, it becomes read-only and then it's hard to work with in an API with generics. In any case, I'd like to avoid this solution at all since adding a type assertion feels like a hack and not a real solution.

In the end, these descriptor objects will be generated with another piece of software I will build, so I will go with the workaround if I have to. I just wanted to see if there are other solutions that I'm not seeing.

The environment I'm working in is constrained to TypeScript 4.7.4, so I have to stick with features available in that version.

答案1

得分: 2

以下是翻译好的代码部分:

type Cast<A, B> = A extends B ? A : B;

type Narrowable = string | number | bigint | boolean;

type Narrow<A> = Cast<
  A,
  [] | (A extends Narrowable ? A : never) | { [K in keyof A]: Narrow<A[K]> }
>;

type ChildType = 'foo' | 'bar' | 'baz';

type ChildDescriptor<
  TName extends string = string,
  TType extends ChildType = ChildType,
> = {
  name: TName;
  type: TType;
};

type ParentDescriptor<
  TSomeProp extends string,
  TChildren extends ChildDescriptor[],
> = {
  someProp: TSomeProp;
  children: [...TChildren];
};

// Identity function so we can observe what types are inferred
const identity = <
  TSomeProp extends string,
  TChildren extends ChildDescriptor[],
>(
  descriptor: ParentDescriptor<TSomeProp, Narrow<TChildren>>,
) => descriptor;

const inferredValue = identity({
  someProp: 'I get inferred',
  children: [
    {
      name: "I don't get inferred",
      type: 'foo',
    },
    {
      name: "I don't get inferred either",
      type: 'baz',
    },
  ],
});

const [
  childA,
  // const childA: { name: "I don't get inferred"; type: "foo"; }
  childB,
  // const childB: { name: "I don't get inferred either"; type: "baz"; }
] = inferredValue.children;

希望这对你有所帮助。

英文:

Here's how you can do it, if you don't want to use as const and make the array readonly -

type Cast&lt;A, B&gt; = A extends B ? A : B;
type Narrowable = string | number | bigint | boolean;
type Narrow&lt;A&gt; = Cast&lt;
A,
[] | (A extends Narrowable ? A : never) | { [K in keyof A]: Narrow&lt;A[K]&gt; }
&gt;;
type ChildType = &#39;foo&#39; | &#39;bar&#39; | &#39;baz&#39;;
type ChildDescriptor&lt;
TName extends string = string,
TType extends ChildType = ChildType,
&gt; = {
name: TName;
type: TType;
};
type ParentDescriptor&lt;
TSomeProp extends string,
TChildren extends ChildDescriptor[],
&gt; = {
someProp: TSomeProp;
children: [...TChildren];
};
// Identity function so we can observe what types are inferred
const identity = &lt;
TSomeProp extends string,
TChildren extends ChildDescriptor[],
&gt;(
descriptor: ParentDescriptor&lt;TSomeProp, Narrow&lt;TChildren&gt;&gt;,
) =&gt; descriptor;
const inferredValue = identity({
//    ^? const inferredValue: ParentDescriptor&lt;&quot;I get inferred&quot;, [{ name: &quot;I don&#39;t get inferred&quot;; type: &quot;foo&quot;; }, { name: &quot;I don...
someProp: &#39;I get inferred&#39;,
children: [
{
name: &quot;I don&#39;t get inferred&quot;,
type: &#39;foo&#39;,
},
{
name: &quot;I don&#39;t get inferred either&quot;,
type: &#39;baz&#39;,
},
],
});
const [
childA,
//^? const childA: { name: &quot;I don&#39;t get inferred&quot;; type: &quot;foo&quot;; }
childB,
//^? const childB: { name: &quot;I don&#39;t get inferred either&quot;; type: &quot;baz&quot;; }
] = inferredValue.children;

Here's a Playground link.

<h2>Explanation</h2>

The reason this works is because of the Typescript compiler scans the input, with it's algorithm, it first of all, scans the generics that are non-contextual and then the ones that are contextual, you can read more about it here and here.

So, if we use the Narrow utility, Typescript is forced to run the analysis for capturing the type on it, and since an Array is really an object, we map over all it's properties, exhaustively, as you can see that this is a recursive utility. This example would give you more insight into it -

type A = { [K in keyof [&quot;some&quot;, &quot;things&quot;]]: [&quot;some&quot;, &quot;things&quot;][K] }
//   ^? type A= { [x: number]: &quot;some&quot; | &quot;things&quot;; 0: “some&quot;; 1: &quot;things&quot;; length: 2; toString: () =&gt; string; toLocaleString: (...

The intermediate Cast utility is where all the magic happens, it's really a trick that makes the compiler infer the type of A, through type B, essentially, if you add two generics and constraint one of them and then use it in another constrained generic, the compiler infers the type like as const, for example -

type Some = number | string;
function getValues&lt;N extends Some, K extends Record&lt;keyof K, N&gt;&gt;(arg: K) {
return arg;
}
const value = getValues({ a: &quot;something&quot;, b: &quot;another thing&quot; });
//    ^? const value: { a: &quot;something&quot;; b: “another thing”; }

huangapple
  • 本文由 发表于 2023年8月10日 20:26:24
  • 转载请务必保留本文链接:https://go.coder-hub.com/76875732.html
  • typescript

我可以将 “ :?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定