TypeScript互斥类型问题:属性二选一及类型推导异常排查与修复
第一个错误:直接访问联合类型的非共有属性报错
你最初定义的Option是一个普通联合类型:
type Option = {name: string, path: string} | {name: string, children: Option[]} declare const option: Option option.path // 报错
这是因为TypeScript在未对联合类型做类型窄化时,只能访问所有分支共有的属性。path只存在于联合类型的第一个分支,children只存在于第二个分支,所以直接访问option.path时,TypeScript无法确定当前option属于哪个分支,因此会抛出“属性不存在”的错误。
第二个错误:XOR类型下的条件判断无法正确窄化类型
你尝试用XOR工具类型实现互斥属性后,出现了不对称的报错情况:
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; type Option = {name: string} & XOR<{path: string}, {children: Option[]}> declare const option: Option if (option.children) option.children.concat() else option.path.toLocaleUpperCase() // 正常 if (option.path) option.path.toLocaleUpperCase() else option.children.concat() // 报错
为什么第二个条件判断会报错?
核心原因是TypeScript的自动类型窄化逻辑无法识别XOR类型中属性的互斥关系。你的XOR类型最终生成的Option本质是两个交叉类型的联合:
// 实际等价结构 type Option = ({name: string} & {path: string} & {children?: never}) | ({name: string} & {children: Option[]} & {path?: never})
当检查option.children存在时,TypeScript能推断出当前分支属于第二个交叉类型(因为children?: never的情况下children只能是undefined,存在则必然是第二个分支)。但反过来,检查option.path存在时,TypeScript并没有自动关联“path存在则children一定不存在”的逻辑——因为path?: never意味着path的类型是undefined | never(即undefined),但TypeScript对这种通过交叉类型实现的隐式互斥,没有内置的关联推断,所以它认为else分支里的option.children可能还是undefined,调用concat()自然会报错。
修复方案
方案1:使用区分联合类型(Discriminated Union)
这是TypeScript中处理互斥类型最推荐的方式,通过添加一个字面量类型的区分字段,让TypeScript可以明确地窄化类型:
type PathOption = { name: string; type: 'path'; path: string }; type ChildrenOption = { name: string; type: 'children'; children: Option[] }; type Option = PathOption | ChildrenOption; declare const option: Option; if (option.type === 'path') { option.path.toLocaleUpperCase(); // 类型正确,无报错 } else { option.children.concat(); // 自动推断为ChildrenOption,无报错 }
这种方式逻辑清晰,类型推断完全可靠,几乎不会出现窄化失败的问题。
方案2:自定义类型守卫函数
如果你不想添加额外的区分字段,可以写自定义类型守卫来手动告诉TypeScript当前分支的类型:
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; type Option = {name: string} & XOR<{path: string}, {children: Option[]}>; // 判断是否是带path的Option function hasPath(option: Option): option is Option & { path: string } { return 'path' in option && option.path !== undefined; } // 判断是否是带children的Option function hasChildren(option: Option): option is Option & { children: Option[] } { return 'children' in option && option.children !== undefined; } declare const option: Option; if (hasPath(option)) { option.path.toLocaleUpperCase(); } else { option.children.concat(); // 类型守卫已完成窄化,无报错 }
方案3:改进XOR类型的定义
调整XOR的实现,让互斥属性的类型更严格,帮助TypeScript更好地推断:
type XOR<T, U> = | (T & Record<Exclude<keyof U, keyof T>, never>) | (U & Record<Exclude<keyof T, keyof U>, never>); type Option = { name: string } & XOR<{ path: string }, { children: Option[] }>; declare const option: Option; if (option.path !== undefined) { option.path.toLocaleUpperCase(); } else { option.children.concat(); // 无报错 }
这里把可选的? never改成了never,让互斥属性的类型从“可选不存在”变成“绝对不能存在”,帮助TypeScript自动关联属性的互斥关系。
内容的提问来源于stack exchange,提问作者aweiu

