typescript - 将对象属性推断为字面量
我正在尝试将一个对象的所有属性推断为字面量。
我很清楚我可以使用const断言。
然而,我不希望生成的类型具有 readonly 属性。
问题在这里:
type Options = {
min?: number;
};
class E1<O extends Options> {
constructor(options: O) {}
}
const e1 = new E1({ min: 1 });
typeof e1 是:
E1<{
min: number; // Oh no! This should be `1`
}>
现在,我知道我可以像这里描述的那样使用一个 const 类型参数:TypeScript 5.0 文档。
让我们试试吧:
class E2<const O extends Options> {
constructor(options: O) {}
}
const e2 = new E2({ min: 1 });
typeof e2 是:
E2<{
readonly min: 1; // Now it's `1`. Great! But it's `readonly`?
}>
我期望的结果是这个:
typeof eN:(eN="示例数字 N")
EN<{
min: 1;
}>
现在我已经尝试了几种解决方案,但没有一种有效:
class E3<const C extends Options> {
constructor(c: { -readonly [K in keyof C]: C[K] }) {}
}
const e3 = new E3({ min: 1 });
typeof e3:
E3<{
readonly min: 1;
}>
或者这个:
type NoRead<T> = {
-readonly [K in keyof T]: T[K];
};
class E4<const C extends NoRead<Options>> {
constructor(c: C) {}
}
const e4 = new E4({ min: 1 });
typeof e4:
E4<{
readonly min: 1;
}>
对此有一个显而易见的解决办法:
class E5<C extends Options> {
constructor(c: C) {}
}
const e5 = new E5({ min: 1 as const });
typeof e5:
E5<{
min: 1;
}>
然而,随着配置对象的增大,const 断言变得不合理。
现在,我已经能看到问题了:“你为什么会想要那个?”
答案是,这主要是为了 IntelliSense 的目的,以减少这种更复杂结构中的视觉负担。
我希望这就是足够的理由。
总结:“我想将泛型的值推断为字面量,同时避免使用readonly修饰符。”
这里是一个 Playground 链接:TS Playground。
答案#1
你可以通过使用“反向映射类型”(参见 microsoft/TypeScript#53018 上的这条评论)来实现这一点,也被称为“从映射类型推断”(如在已弃用的 TS 手册中记录的,但出于某种原因在当前手册中没有提及,尽管这一点没有任何变化)。
也就是说,如果你有一个通用调用或构造签名,形式为 function foo<T>(ft: F<T>): void,那么 TypeScript 有时可以从映射类型 F<T> 的值中推断出类型参数 T。只有在映射类型 F<T> 在 T 中是 同态 的(“同态映射类型”是什么意思?)时,这才可能。它是一个“反向”映射类型,因为如果你调用 foo(fa),TypeScript 必须反向运行映射类型,从输出 F<T> 中获取输入 T。
对于你的示例代码,它看起来像这样:
class E<const C extends Options> {
constructor(c: Readonly<C>) { }
}
这里我们仍然使用 const 类型参数 C 来鼓励字面量类型推断。但我们表示构造函数参数 c 的类型是 Readonly<C>,使用了 Readonly 工具类型(实现为同态映射类型 type Readonly<T>={ readonly [P in keyof T]: T[P]; })。
它的意思是:当你调用 new E(c) 时,TypeScript 会将 c 的类型视为 Readonly<C>。为了从 c 的类型推断出 C,它需要反转Readonly<> 的操作。这意味着 C 在概念上是 typeof c 的 非只读版本:
const e = new E({ min: 1, str: "abc" });
/* const e: E<{ min: 1; str: "abc"; }> */
显然这有效。const 类型参数的行为就像你调用了 new E({min: 1, str: "abc"} as const,这会将 c 推断为类型 {readonly min: 1, readonly str: "abc"},由于这是 Readonly<C>,TypeScript 会将 C 匹配为 {min: 1, str: "abc"}。这其实让我有点惊讶,这能成功,因为从理论上讲 C 仍然可以是 {readonly min: 1, readonly str: "abc"};无论如何 Readonly<C> 都是一样的。但幸运的是,TypeScript 实际上决定应用等同于 -readonly 的映射修饰符。
在反向映射类型不起作用的情况下,你需要自己来做。也就是说,如果 new <C extends Options>(c: Readonly<C>) => E<C> 没有实现,那么 new <C extends Options>(c: C) => E<Mutable<C>> 应该可以实现,其中 type Mutable<T>={ -readonly [K in keyof T]: T[K] }。不幸的是,TypeScript 永远不会为 class 声明推断出这样的类型,这意味着你需要手动写出并为其分配一个构造函数。
class _E<C extends Options> {
constructor(c: C) { }
}
type E<C extends Options> = _E<C>;
const E: new <const C extends Options>(
e: C) => E<{ -readonly [K in keyof C]: C[K] }> = _E;
这会得到相同的结果:
const e = new E({ min: 1, str: "abc" });
/* const e: E<{ min: 1; str: "abc"; }> */
不过这更复杂,所以在可行时,反向映射类型更可取。
代码的 Playground 链接
