网站搜索

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 链接