使用可辨识联合类型默认值时,TypeScript类型推断错误
我实现了一个支持按钮和链接两种形态的React Button组件,用TypeScript做类型约束,核心代码如下:
export type BaseProps = { size?: 'xs' | 'sm' | 'md' | 'lg'; } type ButtonAsButtonProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'as'> & BaseProps & { as?: 'button'; } type ButtonAsLinkProps = Omit<React.ComponentPropsWithoutRef<'a'>, 'as'> & BaseProps & { as: 'link'; link: string; } type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps const Button = ( props : ButtonProps ) => { props.as = props.as ?? 'button' if ( props.as === 'link' ) { return <Link { ... props } /> } return <Box<'button'> as='button' { ... props } /> } export default Button
当显式指定as属性时,类型校验符合预期:
<Button as='button' link='' disabled />会报错link不属于按钮props<Button as='link' link='' disabled />会报错disabled不属于链接props
但当不指定as(默认使用按钮形态)时,传入link、href等链接专属属性,TypeScript却不报错:
// 期望报错,但实际无任何提示 <Button link='' href='' target='' onClick={() => {}} disabled />
这是因为联合类型ButtonProps允许传入同时满足两个分支的交叉类型——只要传入的属性集合属于任意一个分支的超集,TypeScript就会认为它符合联合类型。而我需要让默认形态下只能传入按钮专属的props,禁止链接相关属性。
解决方案
方案1:给按钮分支添加专属属性的禁止约束
在ButtonAsButtonProps中显式禁止链接分支的专属属性(比如link),让两个分支的属性互斥:
export type BaseProps = { size?: 'xs' | 'sm' | 'md' | 'lg'; } type ButtonAsButtonProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'as'> & BaseProps & { as?: 'button'; // 禁止link属性,确保按钮形态下无法传入该属性 link?: never; } type ButtonAsLinkProps = Omit<React.ComponentPropsWithoutRef<'a'>, 'as'> & BaseProps & { as: 'link'; link: string; } type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps const Button = (props: ButtonProps) => { const resolvedAs = props.as ?? 'button'; if (resolvedAs === 'link') { return <Link {...props as ButtonAsLinkProps} />; } return <Box<'button'> as='button' {...props as ButtonAsButtonProps} />; } export default Button
此时,当不指定as并传入link时,TypeScript会发现该属性违反了ButtonAsButtonProps的约束,同时又不满足ButtonAsLinkProps必须传入as='link'的要求,从而抛出正确的类型错误。
方案2:拆分联合类型,明确默认分支
将默认形态(不指定as)单独作为一个类型分支,与显式指定as的分支组成联合,让TypeScript在无as时只能匹配按钮类型:
export type BaseProps = { size?: 'xs' | 'sm' | 'md' | 'lg'; } // 默认形态:不指定as,仅允许按钮相关props type ButtonDefaultProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'as'> & BaseProps; // 显式指定as='button'的形态 type ButtonAsButtonProps = ButtonDefaultProps & { as: 'button' }; // 链接形态 type ButtonAsLinkProps = Omit<React.ComponentPropsWithoutRef<'a'>, 'as'> & BaseProps & { as: 'link'; link: string; }; // 联合类型覆盖所有场景 type ButtonProps = ButtonDefaultProps | ButtonAsButtonProps | ButtonAsLinkProps; const Button = (props: ButtonProps) => { const resolvedAs = 'as' in props ? props.as : 'button'; if (resolvedAs === 'link') { return <Link {...props as ButtonAsLinkProps} />; } return <Box<'button'> as='button' {...props as ButtonDefaultProps} />; } export default Button
这种方式更清晰地划分了不同场景的类型,默认形态下只能传入按钮props,传入link、href等属性会直接触发类型错误。
方案3:使用函数重载
利用TypeScript函数重载特性,为不同入参场景定义精准的类型约束:
import React from 'react'; export type BaseProps = { size?: 'xs' | 'sm' | 'md' | 'lg'; } type ButtonAsButtonProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'as'> & BaseProps & { as?: 'button'; } type ButtonAsLinkProps = Omit<React.ComponentPropsWithoutRef<'a'>, 'as'> & BaseProps & { as: 'link'; link: string; } // 重载签名1:默认形态,无as属性 function Button(props: Omit<ButtonAsButtonProps, 'as'>): JSX.Element; // 重载签名2:显式指定as='button' function Button(props: ButtonAsButtonProps): JSX.Element; // 重载签名3:显式指定as='link' function Button(props: ButtonAsLinkProps): JSX.Element; // 组件实现 function Button(props: ButtonAsButtonProps | ButtonAsLinkProps) { const resolvedAs = props.as ?? 'button'; if (resolvedAs === 'link') { return <Link {...props as ButtonAsLinkProps} />; } return <Box<'button'> as='button' {...props as ButtonAsButtonProps} />; } export default Button
函数重载可以直接为默认场景定义严格的类型,确保不指定as时只能传入按钮相关props,避免联合类型的模糊推断问题。
核心原因
原来的联合类型ButtonProps允许传入同时满足两个分支的交叉类型(比如同时包含按钮和链接的props),因为交叉类型是联合类型的子类型。通过让分支属性互斥、拆分默认分支或使用函数重载,可以让TypeScript在默认场景下精准匹配按钮类型,实现严格的props校验。
内容的提问来源于stack exchange,提问作者grazdev

