可选属性的类型推断
export interface ProcessedResultOptions<
TField1 = undefined,
TField2 = undefined
> {
field1?: TField1;
field2?: TField2;
}
export class ProcessedResult<
const TField1 = undefined,
const TField2 = undefined
> {
public readonly field1: TField1;
public readonly field2: TField2;
constructor(options: ProcessedResultOptions<TField1, TField2>) {
this.field1 = options.field1;
/*
Type 'TField1 | undefined' is not assignable to type 'TField1'.
'TField1' could be instantiated with an arbitrary type which could be unrelated to 'TField1 | undefined'.
*/
this.field2 = options.field2;
}
}
为什么会失败,当field1缺失时,options 的类型应该是 ProcessedResultOptions<undefined, ...>,所以options.field1 (undefined) 应该能赋值给this.field1 (undefined)
我可以用一个简单的 options.field1 as TField1 来解决这个,但我觉得没必要非要这么做。
解决方案
不幸的是,当构造参数的 field1 或 field2 字段为 undefined 时,TField1 或 TField2 泛型类型参数并不一定会包含 undefined。你可以通过引导 inference 让它变得非常可能,但这并不能得到保证。如果你有一个类型为 {x?: X} 的值,那么 x 可能是 undefined,即使 X 不是,这正是编译器在抱怨的地方。
例如,调用方总是可以通过手动指定类型参数来规避类型参数推断:
const r = new ProcessedResult<string, string>({}); // allowed
// ^? const r: ProcessedResult<string, string>;
r.field1.toUpperCase(); // TS accepts this but it's a guaranteed runtime error
这个调用之所以被允许,是因为调用方始终可以这样做。所以现在 TField1 和 Tfield2 是 string,构造函数的参数 options 是 ProcessedResultOptions<string, string>,它接受 {},因为属性是可选的。但现在TypeScript认为 r.field1 和 r.field2 的类型是 string,而它们在运行时很可能是 undefined
再举一个例子,即使你使用了 泛型类型参数默认值 的 undefined,推断在你传入一个 联合类型 值时有时仍会移除 undefined,就像这样:
const r = new ProcessedResult({ field1: Math.random() < 0.9999 ? undefined : "abc" });
// ^? const r: ProcessedResult<string, undefined>
r.field1.toUpperCase(); // TS accepts this but it's 99.99%-guaranteed runtime error
推断在这种情况下“很贴心地”将 undefined 从 TField1 中移除了,因为它将 TField1 | undefined 与 string | undefined 进行模式匹配,得到 string。现在再次出现一种情况,TypeScript认为 r.field1 是肯定的 string,但在运行时很可能是 undefined。
所以这就是你按原问题提问时的答案:TypeScript正在提醒你一个真实存在的、虽然不太可能但仍然存在的类型安全隐患。
正确的做法取决于具体的使用场景。显然你可以直接使用一个 类型断言 以允许构造函数内的赋值。你期望的用例仍然会按你打算的方式工作,以上的边缘情况也许并不会经常发生,因而不必过于担心。
理想情况下,你会想让TypeScript在某人省略字段时,使用 undefined 的默认值来指定类型参数的类型。但这并非TypeScript的一部分。就在 microsoft/TypeScript#58977 有一个非常相似的功能请求,试图让类似 function foo<T>(x: T = "abc") {} 的东西工作,其中 "abc" 的默认参数将用于指定 T。目前这与您的代码失败的原因相同,因为没有任何东西能真正保证 "abc" 能赋值给 T。另外,像 function foo<T = string>(x: T = "abc") 这样的泛型类型参数默认值也没有帮助。
在没有理想方法的情况下,只有一些变通办法。一般来说,一种常见的变通方法是将 undefined 类型替换为 unknown,因为TypeScript并不能真正确定一个为 {field1: string} 的值是否真的缺少 field2 字段。因此 field2 的类型变成了 unknown,而不是 undefined:
interface Hmm { field1: string };
const v = { field1: "", field2: 3 };
const hmm: Hmm = v;
const r = new ProcessedResult(hmm);
r.field2; // <-- this is 3 at runtime, not undefined.
如果是这样,那么你可以让你的类在 options 的类型上实现泛型,并通过 索引访问类型 将旧的 TField1 和 TField2 从它们派生出来:
interface ProcessedResultOptions {
field1?: unknown;
field2?: unknown;
}
class ProcessedResult<const T extends ProcessedResultOptions> {
public readonly field1: T["field1"];
public readonly field2: T["field2"];
constructor(options: T) {
this.field1 = options.field1;
this.field2 = options.field2;
}
}
const r = new ProcessedResult({field1: ""});
// ^? const r: ProcessedResult<{ readonly field1: "" }>
r.field1.toUpperCase();
r.field2
// ^? (property) ProcessedResult<{ readonly field1: ""; }>.field2: unknown
r.field2.toUpperCase(); // compiler error
这也许不是你想走的路,而且还存在其他注意事项,所以我不打算再展开。就个人而言,我会在合适的地方使用类型断言,并接受某些边缘情况可能会打破我的假设。