Infer generic subtype in dynamic record

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

Infer generic subtype in dynamic record

问题

我试图创建一个配置管理类,它可以使用一些解析策略来映射键。在这样做时,我想要一个访问函数,它从动态映射中检索值和正确的类型。

type ParameterDescription<T> = {
  default: T;
};

type ParameterDictionary = Record<
  string,
  ParameterDescription<string | number | boolean>
>;

type ParameterSubType<T> = T extends ParameterDescription<infer U> ? U : never;

const config: ParameterDictionary = {
  foo: { default: "test" },
  bar: { default: 1 },
  baz: { default: true },
};

function get<T extends ParameterDictionary, K extends keyof T>(config: T, key: K): ParameterSubType<T[K]>;
function get<T extends ParameterDictionary>(config: T, key: string): undefined {
  return config[key].default || undefined;
}

const a = get(config, "foo"); // 类型为 string
const b = get(config, "bar"); // 类型为 number
const c = get(config, "baz"); // 类型为 boolean
const d = get(config, "boz"); // 类型为 undefined

这种方式是可行的吗?我想避免声明一个明确列出键类型的接口,例如:

interface {
  foo: ParameterDescription<string>
  bar: ParameterDescription<number>
}
英文:

I'm trying to create a configuration management class that maps keys with some resolution strategies. In doing so, I'd like to have an accessor function that retrieves the value, and the correct type from the dynamic map.

type ParameterDescription&lt;T&gt; = {
  default: T;
};

type ParameterDictionary = Record&lt;
  string,
  ParameterDescription&lt;string | number | boolean&gt;
&gt;;

type ParameterSubType&lt;T&gt; = T extends ParameterDescription&lt;infer U&gt; ? U : never;

const config: ParameterDictionary = {
  foo: { default: &quot;test&quot; },
  bar: { default: 1 },
  baz: { default: true },
};

function get&lt;T extends ParameterDictionary, K extends keyof T&gt;(config: T, key: K): ParameterSubType&lt;T[K]&gt;;
function get&lt;T extends ParameterDictionary&gt;(config: T, key: string): undefined {
  return config[key].default || undefined;
}

const a = get(config, &quot;foo&quot;); // type is string
const b = get(config, &quot;bar&quot;); // type is number
const c = get(config, &quot;baz&quot;); // type is boolean
const d = get(config, &quot;boz&quot;); // type is undefined

Is something like this possible? I'd like to avoid having to declare an interface that explicitly lists the key types; ie:

interface {
  foo: ParameterDescription&lt;string&gt;
  bar: ParameterDescription&lt;number&gt;
}

答案1

得分: 1

您主要的问题是您已经使用类型注释将 config 变量标注为 ParameterDictionary 类型,如下所示:

const config: ParameterDictionary = {
  foo: { default: "test" },
  bar: { default: 1 },
  baz: { default: true },
}

但是通过这样做,您明确放弃了编译器可能从对象字面量初始化程序中获取的更具体的信息。相反,您应该让编译器推断它的类型:

const config = {
  foo: { default: "test" },
  bar: { default: 1 },
  baz: { default: true },
};

或者,如果您确实希望在 config 无法成为 ParameterDictionary 时看到某种警告,您可以在初始化程序上使用 satisfies 操作符

const config = {
  foo: { default: "test" },
  bar: { default: 1 },
  baz: { default: true },
} satisfies ParameterDictionary;

一旦这样做,编译器将知道 config.foo.defaultstring 等类型。

然后,您需要修改 get() 的调用签名,以允许 key 是任何属性键,而不仅仅是 config 的已知键。可能像这样:

function get<T extends ParameterDictionary, K extends PropertyKey>(
  config: T, key: K
): K extends keyof T ? T[K]["default"] : undefined;

function get(config: ParameterDictionary, key: string) {
  return config[key]?.default;
}

这里 get() 的返回类型是一个 条件类型,它检查 Kkey 的类型)是否已知为 Tconfig 的类型)的键。如果是这样,您将获得 T[K]default 属性的类型。如果不是,则将获得 undefined。这会给您所期望的行为:

const a = get(config, "foo"); // const a: string
const b = get(config, "bar"); // const b: number
const c = get(config, "baz"); // const c: boolean
const d = get(config, "boz"); // const d: undefined

这解决了您的实现问题。

这就是您所提出的问题的答案。这里有一个注意事项:TypeScript 对象类型不是“封闭”的;仅因为一个类型没有提到一个键,并不意味着该键不存在于该类型的值中。因此,在键不被认为存在时返回 undefined 在技术上可能不正确。例如:

const config2 = {
  foo: { default: "test" },
  bar: { default: 1 },
  baz: { default: true },
  oops: { default: "oops" }
} satisfies ParameterDictionary; // okay

const oops: typeof config = config2; // okay
const e = get(oops, "oops");
// const e: undefined
console.log(e); // "oops" <-- 不是 undefined

这相当不可能发生,因为它依赖于别名和扩展,但您可能需要知道。实现 get() 的“安全”方式要么是将返回值设为 unknown 而不是 undefined,要么是使编译器拒绝任何不已知的键。但这偏离了问题,所以我会在这里停止。

英文:

Your main problem is that you annotated the config variable to be of type ParameterDictionary like

const config: ParameterDictionary = {
  foo: { default: &quot;test&quot; },
  bar: { default: 1 },
  baz: { default: true },
}

but by doing that you are explicitly throwing away any more specific information the compiler might have had from the object literal initializer. Instead you should probably just let the compiler infer its type:

const config = {
  foo: { default: &quot;test&quot; },
  bar: { default: 1 },
  baz: { default: true },
};

Or, if you really want to see some kind of warning if config fails to be a ParameterDictionary, you could use the satisfies operator on the initializer:

const config = {
  foo: { default: &quot;test&quot; },
  bar: { default: 1 },
  baz: { default: true },
} satisfies ParameterDictionary;

Once you do that, the compiler will know that config.foo.default is a string, etc.


Then you need to modify the call signature of get() to allow key to be any property key and not just known keys of config. Perhaps like this:

function get&lt;T extends ParameterDictionary, K extends PropertyKey&gt;(
  config: T, key: K
): K extends keyof T ? T[K][&quot;default&quot;] : undefined;

function get(config: ParameterDictionary, key: string) {
  return config[key]?.default; // &lt;-- note that this fixes your broken impl
}

Here the return type of get() is a conditional type that checks if K (the type of key) is known to be a key of T (the type of config). If so, you get the type of the default property of T[K]. If not, you get undefined. This gives you the desired behavior:

const a = get(config, &quot;foo&quot;); // const a: string
const b = get(config, &quot;bar&quot;); // const b: number
const c = get(config, &quot;baz&quot;); // const c: boolean
const d = get(config, &quot;boz&quot;); // const d: undefined

(Note that I had to fix the implementation so that the last would be undefined at runtime and not a TypeError)


So that's the answer to the question as asked. There's a caveat here: TypeScript object types are not "sealed"; just because a type doesn't mention a key, doesn't mean there isn't such a key present on a value of that type. So technically returning undefined when a key isn't known to exist might not be correct. For example:

const config2 = {
  foo: { default: &quot;test&quot; },
  bar: { default: 1 },
  baz: { default: true },
  oops: { default: &quot;oops&quot; }
} satisfies ParameterDictionary; // okay

const oops: typeof config = config2; // okay
const e = get(oops, &quot;oops&quot;);
// const e: undefined
console.log(e); // &quot;oops&quot; &lt;-- not undefined

That's pretty unlikely, since it relies on aliasing and widening, but you might want to be aware. The "safe" way to implement get() is either to have the return value be unknown instead of undefined, or to make the compiler reject any keys that aren't known. But that's digressing from the question, so I'll stop here.

Playground link to code

huangapple
  • 本文由 发表于 2023年6月30日 02:53:36
  • 转载请务必保留本文链接:https://go.coder-hub.com/76583886.html
匿名

发表评论

匿名网友

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

确定