判别联合类型与类型转换

前端开发 2026-07-10

我有一个函数,用来改变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

一个关于 'foo' 在 'InputB' 上不存在的 TypeScript 错误

Playground链接

如果我把逻辑改成在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 以了解处理它们的唯一受支持的方式——避免条件控制流、改为对映射类型进行索引。如果你真的非常在意实现层面的类型安全,这种重构也许值得。否则,你大概只需要像这样使用断言然后继续前进。

代码的Playground链接

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章