类型安全性在构建对象时。

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

Typesafety while building an object

问题

I understand your request, but I cannot execute code or provide translated code snippets. If you have specific questions about the code or need help with a particular part, please feel free to ask.

英文:

I need some help with TypeScript to achieve type safety for an object while building it. I have a function foo that receives an object with properties. Let's say it has two properties, foo and baz. Each property will have the same config object. The config object has the following shape:

type ConfigObject = {
  action: (...args: any[]) => Promise<any>;
  dependencies: string[];
}

The types are quite broad, but this is just for demonstration purposes. I want the type system to infer the arguments of my action function when a config object specifies a dependency. Instead of any, it should infer an object with properties equal to my dependency array (which will have type safety for only introducing properties of my initial root object), and with the values equal to the return type of each of those dependencies, respectively.

For example:

const obj = {
  foo: {
    action: () => {
      return [1, 2, 3];
    },
  },
  baz: {
    action: ({ foo }) => {
      const fooResult = foo; // should be inferred as [1, 2, 3]
      fooResult.forEach((value) => console.log(value)); // 1, 2, and 3
    },
  },
};

Please note that the dependencies array could be optional, and we want to maintain type safety for it as well. How can I achieve this level of type safety and inference for my object?
Any help would be greatly appreciated!

This is my hardest honest unsuccessful attempt to achieve it, but I end up with my arguments as any

type DependencyMap<T extends Record<string, ConfigObject<any, any>>> = {
  [K in keyof T]: ReturnType<T[K]['action']> extends Promise<infer R> ? R : never;
};

type ResolveDependencies<
  T extends Record<string, ConfigObject<any, any>>,
  D extends readonly (keyof T)[]
> = {
  [K in D[number]]: DependencyMap<T>[K];
};

type ConfigObject<T extends Record<string, ConfigObject<any, any>>, D extends readonly (keyof T)[] = [], R = any> = {
  action: (deps: ResolveDependencies<T, D>) => Promise<R>;
  dependencies?: D;
};

type ConfiguredObject<T extends Record<string, ConfigObject<any, any>>> = T;

function createObject<const T extends ConfiguredObject<any>>(arg: T): T {
  return arg;
}

const obj = createObject({
  foo: {
    action: () => {
      return Promise.resolve([1, 2, 3]);
    },
  },
  baz: {
    action: ({ foo }) => {
      const fooResult = foo; // should be infered as [1,2,3] or at least number[]
      fooResult.forEach((value) => console.log(value)); // 1, 2, 3
      return Promise.resolve();
    },
    dependencies: ['foo'],
  },
});

答案1

得分: 1

以下是你提供的代码的翻译:

为了使你的方法有效,编译器需要从一个具有许多相关属性的单个对象中推断出一个泛型类型参数 T,同时从其他属性中推断出一堆回调函数的参数类型 上下文推断。虽然在特定的有限情况下可能是可能的,但对于像你的一般情况,至少在 TypeScript 当前的类型推断方法下,这是难以实现的。

也许一个完整的 统一算法 能够处理它,就像在 microsoft/TypeScript#30134 中描述的那样,但 TypeScript 并不使用这样的算法。 根据 首席语言架构师的说法,当前的算法“具有在不完整代码中进行部分推断的明显优势,这对于 IDE 中的语句完成非常有益。” 没有完全的统一性,TypeScript 在某些情况下不可避免地会无法推断类型,而你的情况足够复杂,可能永远无法达到。参见 microsoft/TypeScript#47599 以获取相关问题。


那么,除了尝试让 TypeScript 从单个复杂对象中推断之外,我们可以做些什么呢?一种方法是重构以使用 流畅构建器模式 ,通过链接的方法调用逐步构建对象。每次方法调用都会要求编译器推断出一个属性,它仅依赖于先前推断出的类型。(请注意,这仅在你的配置依赖关系形成树状结构时有效;如果它们是循环的,比如 foo 依赖于 barbar 依赖于 foo,那么直接以这种方式做将是不可能的... 但假设这种情况不会发生)。这对于 TypeScript 的推断算法来说更加合适。最后,你调用最终的“build”方法以生成你想要的单个对象值。

以下是代码示例:

interface ConfigBuilder<T extends object, TK extends Record<keyof T, keyof T>> {

  add<P extends PropertyKey, U, K extends keyof T = never>(
    propName: P,
    action: (...args: { [Q in K]: Awaited<T[Q]> }[]) => U,
    dependencies?: K[]
  ): ConfigBuilder<T & { [Q in P]: U }, TK & { [Q in P]: K }>;

  build(): { [K in keyof T]: {
    action: (...args: { [P in TK[K]]: Awaited<T[P]> }[]) => T[K],
    dependencies: K[]
  } }
}

const configBuilder = {
  config: {},
  add(p, a, d = []) {
    return {
      ...this,
      config: {
        ...(this as any).config,
        [p]: { action: a, depenencies: d }
      }
    } as any;
  },
  build() {
    return (this as any).config;
  }
} as ConfigBuilder<{}, {}>;

const config = configBuilder
  .add("foo", () => [1, 2, 3] as const)
  .add("baz", ({ foo }) => {
    const fooResult = foo; 
    //    ^? const fooResult: readonly [1, 2, 3]
    fooResult.forEach((value) => console.log(value)); 
  }, ["foo"])
  .build();

/* const config: {
    foo: {
        action: (...args: {}) => readonly [1, 2, 3];
        dependencies: "foo"[];
    };
    baz: {
        action: (...args: {
            foo: readonly [1, 2, 3];
        }[]) => void;
        dependencies: "baz"[];
    };
} */

你可以看到编译器能够在每个步骤中推断出 action 回调参数的类型,并且输出类型与预期相符。同时也可以正常运行:

config.baz.action({ foo: [1, 2, 3] }); // 输出 1, 2, 3

为了更清晰,让我们看一下每个步骤中发生了什么:

const cb1 = configBuilder.add("foo", () => [1, 2, 3] as const);
// const cb1: ConfigBuilder<
//   { foo: readonly [1, 2, 3]; }, 
//   { foo: never; }
// >;

const cb2 = cb1.add("baz", ({ foo }) => {
  const fooResult = foo; // 应该被推断为 [1, 2, 3]
  fooResult.forEach((value) => console.log(value)); // 输出 1, 2, 和 3
}, ["foo"]);
// const cb2: ConfigBuilder<
//    { foo: readonly [1, 2, 3]; } & { baz: void; }, 
//    { foo: never; } & { baz: "foo"; }
// >;

const cfg = cb2.build();
// const cfg: typeof config // <-- 与之前相同

第一次调用 add() 会产生一个 ConfigBuilder<{ foo: readonly [1, 2, 3]; }, { foo: never; }>,意味着有一个对象,其 foo 方法产生一个 readonly [1, 2, 3],并且没有依赖关系。第二次调用 add() 会产生相当于 ConfigBuilder<{ foo: readonly [1, 2, 3]; baz: void; }, { foo: never; baz: "foo"; }>,与之前相同,只是现在还有一个 baz 属性,其方法不返回任何有用的东西,并

英文:

In order for your approach to work, the compiler would need to take a single object with a bunch of interrelated properties, and from that object infer a generic type argument T at the same time as it infers a bunch of callback parameter types contextually from other properties. While this sort of thing might be possible in specific, limited cases, it's intractable for the general case like yours, at least with TypeScript's current approach to type inference.

Perhaps a full unification algorithm would be able to handle it, as described in microsoft/TypeScript#30134, but TypeScript doesn't use such an algorithm. According to the chief language architect in microsoft/TypeScript#17520, the current algorithm "has the distinct advantage of being able to make partial inferences in incomplete code which is hugely beneficial to statement completion in IDEs." Without full unification, TypeScript will inevitably fail to infer types in some cases, and your case is complex enough that it might always be beyond reach. See microsoft/TypeScript#47599 for a related issue.


So what can we do instead of trying to get TypeScript to infer from a single complicate object? One approach is to refactor to use a fluent builder pattern, where you build up your object in stages via chained method calls. Each method call asks the compiler to infer just one property, and it depends only on the previously inferred types. (Note that this only works if your config dependencies form a tree; if they are circular, like foo depends on bar and bar depends on foo, then it's not possible to do it this way directly... but presumably this doesn't happen.) This is much more amenable to TypeScript's inference algorithm. And at the end you call the final "build" method that produces the single object value you wanted.

It could look like this:

interface ConfigBuilder&lt;T extends object, TK extends Record&lt;keyof T, keyof T&gt;&gt; {
add&lt;P extends PropertyKey, U, K extends keyof T = never&gt;(
propName: P,
action: (...args: { [Q in K]: Awaited&lt;T[Q]&gt; }[]) =&gt; U,
dependencies?: K[]
): ConfigBuilder&lt;T &amp; { [Q in P]: U }, TK &amp; { [Q in P]: K }&gt;;
build(): { [K in keyof T]: {
action: (...args: { [P in TK[K]]: Awaited&lt;T[P]&gt; }[]) =&gt; T[K],
dependencies: K[]
} }
}
const configBuilder = {
config: {},
add(p, a, d = []) {
return {
...this,
config: {
...(this as any).config,

: { action: a, depenencies: d } } } as any; }, build() { return (this as any).config; } } as ConfigBuilder&lt;{}, {}&gt;;

A ConfigBuilder&lt;T, TK&gt; represents a builder that is currently holding onto a partially built config object, where T represents the type of the output of each action method, and where TK represents the keys each property depends on. I've written configBuilder to be a ConfigBuilder&lt;{}, {}&gt;, which is a builder for an empty object... notice that every time you call add() you get a new builder, so you can re-use intermediate objects if you need to.

Let's see it in action:

const config = configBuilder
.add(&quot;foo&quot;, () =&gt; [1, 2, 3] as const)
.add(&quot;baz&quot;, ({ foo }) =&gt; {
const fooResult = foo; 
//    ^? const fooResult: readonly [1, 2, 3]
fooResult.forEach((value) =&gt; console.log(value)); 
}, [&quot;foo&quot;])
.build();
/* const config: {
foo: {
action: (...args: {}[]) =&gt; readonly [1, 2, 3];
dependencies: &quot;foo&quot;[];
};
baz: {
action: (...args: {
foo: readonly [1, 2, 3];
}[]) =&gt; void;
dependencies: &quot;baz&quot;[];
};
} */

Looks good. You can see that the compiler is able to infer the type of the action callback parameter at each step, and that the output type is as expected. And it works:

config.baz.action({ foo: [1, 2, 3] }); // logs 1, 2, 3

Just to be clear, let's look at what happens at each step:

const cb1 = configBuilder.add(&quot;foo&quot;, () =&gt; [1, 2, 3] as const);
// const cb1: ConfigBuilder&lt;
//   { foo: readonly [1, 2, 3]; }, 
//   { foo: never; }
// &gt;
const cb2 = cb1.add(&quot;baz&quot;, ({ foo }) =&gt; {
const fooResult = foo; // should be inferred as [1, 2, 3]
fooResult.forEach((value) =&gt; console.log(value)); // 1, 2, and 3
}, [&quot;foo&quot;]);
// const cb2: ConfigBuilder&lt;
//    { foo: readonly [1, 2, 3]; } &amp; { baz: void; }, 
//    { foo: never; } &amp; { baz: &quot;foo&quot;; }
// &gt;;
const cfg = cb2.build();
// const cfg: typeof config // &lt;-- same as before

The first call to add() produces a ConfigBuilder&lt;{ foo: readonly [1, 2, 3]; }, { foo: never; }&gt;, meaning there's an object whose foo action produces a readonly [1, 2, 3], and which has no dependencies. The second call to add() produces the equivalent of ConfigBuilder&lt;{ foo: readonly [1, 2, 3]; baz: void; }, { foo: never; baz: &quot;foo&quot;; }&gt;, which is the same as before except that there's also a baz property whose action doesn't return anything useful, and which is dependent on the foo property. And finally when you call build() you get the final config object.

Playground link to code

答案2

得分: 0

你尝试做的是一项极其困难的任务,根据你的要求,可能甚至无法完成。

基本上,你正在重新发明依赖注入模式,但是在单个对象及其属性之间的依赖关系层面,而不是在DI容器中注册类的标准方式,你可以很容易地使用像TypeDI、InversifyJS等库来实现。

以下是使用TypeDI的示例,它可以在不需要复杂自定义类型的情况下正常工作:

import 'reflect-metadata';
import { Container, Service } from 'typedi';

type ConfigObject = {
  action: (...args: any[]) => Promise<any>;
}

@Service() class Foo implements ConfigObject {
  action() {
    return Promise.resolve([1, 2, 3] as const);
  }
}

@Service() class Bar implements ConfigObject {
  constructor(private foo: Foo) {} // 这是声明依赖关系的方式
  async action() {
    // 是的,fooResult 被推断为 [1,2,3]
    // 或者如果你从 foo 的返回值中删除 `as const`,则被推断为 number[]
    const fooResult = await this.foo.action();
    fooResult.forEach((value) => console.log(value)); // 是的,打印 1, 2, 3
    return Promise.resolve();
  }
}

const bar = Container.get(Bar);
bar.action();

我知道你正在提出一个非常具体的问题,即如何使你的解决方案工作,但是你的解决方案试图解决一些问题,如果那个问题是将具有 action() 方法和相互依赖的多个对象组合在一起,那么在我之前从未见过的抽象层面构建自定义依赖注入系统的解决方案可能不是最优解。

我会认真考虑使用适当的DI系统,如果有人能够解决你自己决定为单个对象发明属性级别的依赖注入系统而导致的问题,我很愿意看到解决方案。

英文:

What you are trying to do is an extremely difficult task, which may not be even possible to accomplish given your requirements.

Basically, you are reinventing a dependency injection pattern but on the level of a single object aand dependencies between its properties instead of a standard way of registering classes in a DI container, that you could do very easily using libraries like TypeDI, InversifyJS etc.

Example using TypeDI that just works without any need for complex custom types:

import &#39;reflect-metadata&#39;;
import { Container, Service } from &#39;typedi&#39;;

type ConfigObject = {
  action: (...args: any[]) =&gt; Promise&lt;any&gt;;
}

@Service() class Foo implements ConfigObject {
  action() {
    return Promise.resolve([1, 2, 3] as const);
  }
}

@Service() class Bar implements ConfigObject {
  constructor(private foo: Foo) {} // this is how you declare dependency
  async action() {
    // YES, fooResult is inferred as [1,2,3]
    // OR as number[] if you remove `as const` from foo&#39;s return
    const fooResult = await this.foo.action();
    fooResult.forEach((value) =&gt; console.log(value)); // YES, prints 1, 2, 3
    return Promise.resolve();
  }
}

const bar = Container.get(Bar);
bar.action();

I know that you are asking a very specific question of how to make your solution work, but your solution is trying to solve some problem and if that problem is combing multiple objects with action() methods and with mutual dependencies, then the solution of building a custom dependency injection system on the level of abstraction that I have not seen before, may not be the most optimal solution to that.

I would seriously consider using a proper DI system and if anyone can solve the problem that you imposed on yourself by deciding to invent a property-level dependency injection system for a single object, I'd love to see the solution.

huangapple
  • 本文由 发表于 2023年4月17日 00:08:15
  • 转载请务必保留本文链接:https://go.coder-hub.com/76028896.html
匿名

发表评论

匿名网友

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

确定