判别联合类型与类型转换
我有一个函数,用来改变API输入的类型(这里给出一个简单的伪示例):
interface BaseInput {
version: '3' | '4';
}
interface InputA extends BaseInput{
version: '4';
foo: string;
}
interface InputB extends BaseInput{
version: '3';
bar: string;
}
const convert3to4 = (apiBodyInput: InputB) => {
return { version: '4', foo: apiBodyInput.bar } as InputA;
};
const convert4to3 = (apiBodyInput: InputA) => {
return { version: '3', bar: apiBodyInput.foo } as InputB;
};
// adapted from https://dev.to/maikelev/discriminated-unions-in-typescript-5fia#exhaustiveness-checking
function assertNever(version: never): never {
throw new Error(`Unrecognised api version: ${version}`);
}
export const changeApiVersion = (apiBodyInput: InputA | InputB, targetVersion: '3' | '4') => {
if(apiBodyInput.version !== '3' && apiBodyInput.version !== '4'){
return assertNever(apiBodyInput)
}
switch(targetVersion) {
case '4':
return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput;
case '3':
return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput;
default:
return assertNever(targetVersion);
}
}
把鼠标悬停在switch语句的每个分支上,TypeScript能判断每个返回值只能是某一种类型(第一种情况总是返回 InputA,第二种情况总是返回 InputB)。
但当我使用它时(例如 const myFoo = changeApiVersion({version: '3', bar: 'sad clown noise'}, 4).foo ← ts错误),TypeScript会告诉我输出是 InputA | InputB:
如果我把逻辑改成在switch的 case中检查 apiBodyInput.version,并在if/else的目标版本处进行检查,我会得到同样的问题(只是现在每个分支的输出都不一样),例如:
export const changeApiVersion = (apiBodyInput: InputA | InputB, targetVersion: '3' | '4') => {
if(targetVersion !== '3' && targetVersion !== '4'){
return assertNever(targetVersion)
}
switch(apiBodyInput.version) {
case '3':
return targetVersion === '4' ? convert3to4(apiBodyInput) : apiBodyInput;
case '4':
return targetVersion === '3' ? convert4to3(apiBodyInput) : apiBodyInput;
default:
return assertNever(apiBodyInput);
}
}
无论哪种情况,为什么在转换后 changeApiVersion 没有收窄类型?
解决方案
基于你对 changeApiVersion 的实现,TypeScript已经推断出该函数的 调用签名 为
const changeApiVersion: (apiBodyInput: InputA | InputB, targetVersion: "3" | "4") =>
InputA | InputB
这并不算错,但它并没有给你期望的行为,即返回类型实际上取决于作为 targetVersion 传入的值。如果你希望一个函数的返回类型(而不仅仅是返回值)随输入而变化,那么你需要让该函数要么成为一个 重载 的函数,要么成为一个 泛型 的函数。
TypeScript永远不会从实现中推断出一个重载或泛型的调用签名。你需要通过注解手动完成。完成后,你还需要想办法说服TypeScript让你的实现与其调用签名相对应。这种说服可能只是等价于一个 类型断言,也就是你直接告诉TypeScript你做对了,它会相信你;或者可能需要经历一系列步骤,让TypeScript的类型检查器实际上得出你做对了的结论。断言要容易得多。
总之,你要么对你的函数进行重载,要么让它成为泛型。为便于讨论,我先定义这些辅助类型:
type Input = InputA | InputB
type Version = Input["version"];
你可以对你的函数进行 重载,把你希望函数被调用的所有方式列出,然后再单独实现它。对于本例来说,这看起来像
declare function changeApiVersion(apiBodyInput: Input, targetVersion: '3'): InputB;
declare function changeApiVersion(apiBodyInput: Input, targetVersion: '4'): InputA;
我这里没有给出实现。这只是调用方看到的内容。无论如何,你可以验证现在它按预期工作:
const myFoo = changeApiVersion({ version: '3', bar: '' }, '4').foo; // okay
只要你能很容易地寫出调用方希望如何使用你的函数的所有方式,重载就足够好了。在这个例子中,返回类型似乎真的只取决于 targetVersion 的类型,因此你需要为并集中的每一个成员写一个调用签名。这在扩展性上是可以的,但可能并不是你想要做的。
或者你可以把它做成 泛型,用通用的类型参数及其之间的关系来表达。你需要写一个从输入类型到输出类型的映射,如下所示:
interface InputMap {
"4": InputA;
"3": InputB;
}
declare function changeApiVersion<T extends Version>(
apiBodyInput: Input, targetVersion: T): InputMap[T];
这在一个单一的调用签名中表达了你对返回类型的意图,且它依赖于类型参数 T。我使用 索引访问类型 在 InputMap 映射类型中查找 T。这比一组重载更容易维护。你可能会反对现在 InputMap 必须随着时间增长,但我只是把 InputMap 写成那样,以便清楚它的作用。你实际上可以像 Input 那样把它“计算”出来:
type InputMap = { [T in Input as T["version"]]: T };
只要你维护好 Input 的并集,InputMap 的定义以及因此 changeApiVersion 的类型就会跟上。再次地,你可以验证这对调用方是有效的:
const myFoo = changeApiVersion({ version: '3', bar: '' }, '4').foo; // okay
太好了,调用方会很开心。挑战在于让TypeScript接受你的函数实现,同时不过度牺牲类型安全。重载通常写成函数声明,其实现的检查非常宽松。很容易写成
function changeApiVersion(apiBodyInput: Input, targetVersion: '3'): InputB;
function changeApiVersion(apiBodyInput: Input, targetVersion: '4'): InputA;
function changeApiVersion(apiBodyInput: Input, targetVersion: Version) {
switch (targetVersion) {
case '3':
return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput;
case '4':
return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput;
}
}
并让它在编译时没有错误。很好,对吧?嗯,除了我搞错把 case '3' 的实现和 case '4' 交换了。哎呀!所以如果你写重载,你需要非常小心检查实现,因为TypeScript大多无法做到。
如果你希望你的重载是函数表达式而不是语句,那么你将需要实际的类型断言,因为现在TypeScript会报错,即使你写得很对:
interface ChangeApiVersion {
(apiBodyInput: Input, targetVersion: '3'): InputB;
(apiBodyInput: Input, targetVersion: '4'): InputA;
}
const changeApiVersion: ChangeApiVersion = (apiBodyInput: Input, targetVersion: Version) => {
// ~~~~~~~~~~~~~~~~ ERROR!
switch (targetVersion) {
case '4':
return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput;
case '3':
return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput;
}
}
断言可以解决编译器的警告,但并不能帮助安全性问题:
const changeApiVersion: ChangeApiVersion = ((apiBodyInput: Input, targetVersion: Version) => {
switch (targetVersion) {
case '4':
return apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput;
case '3':
return apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput;
}
}) as ChangeApiVersion
对于泛型,你也差不多处在同样的情况。TypeScript无法轻易验证你在 switch/case 的表达式实现是否真正能满足一个泛型调用签名,因此你会遇到相同的错误和断言:
function changeApiVersion<T extends Version>(apiBodyInput: Input, targetVersion: T): InputMap[T] {
switch (targetVersion) {
case '4':
return (apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput); // ERROR!
case '3':
return (apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput); // ERROR!
}
}
function changeApiVersion<T extends Version>(apiBodyInput: Input, targetVersion: T): InputMap[T] {
switch (targetVersion) {
case '4':
return (apiBodyInput.version === '3' ? convert3to4(apiBodyInput) : apiBodyInput) as InputMap[T];
case '3':
return (apiBodyInput.version === '4' ? convert4to3(apiBodyInput) : apiBodyInput) as InputMap[T];
}
}
就到这里。我确实可以重构 changeApiVersion 的实现以及它的签名,让TypeScript在你写错时能发出警告、在你写对时不发出警告。只是这涉及大量的复杂性。代码大致如下所示:
interface InputVersions {
'3': { bar: string },
'4': { foo: string }
}
type Version = keyof InputVersions;
type Input<K extends Version = Version> = { [P in K]: { version: P } & InputVersions[P] }[K]
type InputA = Input<'4'>
type InputB = Input<'3'>
const convert3to4 = (apiBodyInput: InputB): InputA => { return { version: '4', foo: apiBodyInput.bar }; };
const convert4to3 = (apiBodyInput: InputA): InputB => { return { version: '3', bar: apiBodyInput.foo }; };
type ConverterFn<KF extends Version, KT extends Version> = (apiBodyInput: Input<KF>) => Input<KT>
function changeApiVersion<KF extends Version, KT extends Version>(
apiBodyInput: Input<KF>,
targetVersion: KT
): Input<KT> {
const convertFromMapToMap: { [F in Version]: { [T in Version]: ConverterFn<F, T> } } = {
"3": { "3": x => x, "4": convert3to4 },
"4": { "3": convert4to3, "4": x => x }
}
const convertFromToMap: { [T in Version]: ConverterFn<KF, T> } = convertFromMapToMap[apiBodyInput.version];
const convertFromTo: ConverterFn<KF, KT> = convertFromToMap[targetVersion];
return convertFromTo(apiBodyInput);
}
changeApiVersion({ version: '3', bar: '' }, '4').foo; // okay
convertFromMapToMap 的定义在不把它写成 switch 或条件判断的情况下捕捉了你的控制流。你会看到如果你调换分支就会得到错误。至于为什么要这样写?这可能超出本文的范围。如果你感兴趣,可以阅读 microsoft/TypeScript#30581 以了解TypeScript对所谓的“相关联合”的基本问题,然后再阅读 microsoft/TypeScript#47109 以了解处理它们的唯一受支持的方式——避免条件控制流、改为对映射类型进行索引。如果你真的非常在意实现层面的类型安全,这种重构也许值得。否则,你大概只需要像这样使用断言然后继续前进。
