Búsqueda de sitios web

reactjs - Uniones exactamente uno/discriminadas para props de react


Estoy intentando definir un tipo de TypeScript para las propiedades de un componente de React. Mi componente es un botón básico que acepta una propiedad icon o una propiedad text. No puede tener ambas, pero debe tener una.

Estaba intentando comenzar con una unión discriminada básica, pero no funciona como esperaba:

interface TextButtonProps extends TypedButtonProps {
  text: string
}

interface IconButtonProps extends TypedButtonProps {
  icon: JSX.Element
}

export const Button = ({ onClick, ...props }: IconButtonProps | TextButtonProps): JSX.Element => {
//...

Cuando uso ese componente en otro lugar, TS no arroja ningún error:

<Button icon={<IconClose />} text='test' uiVariant='default' />

Siguiendo un artículo que encontré en línea, describiendo las interfaces con propiedades opcionales y never funciona:

interface TextButtonProps extends TypedButtonProps {
  text?: string
  icon?: never
}

interface IconButtonProps extends TypedButtonProps {
  icon?: JSX.Element
  text?: never
}

Todos mis usos de <Button> lanzarán un error si existen tanto icon como text.

¿Por qué funciona eso? No estoy contento con lo verboso que es; si agrego más tipos de botones, tengo que agregar esas nuevas propiedades a cada interfaz.

Mi segundo problema es que, dado que las propiedades son opcionales, puedo evitar definir tanto el icono como la propiedad de texto; recuerda que necesito asegurarme de que uno u otro exista.

¿Hay una solución más limpia que satisfaga mis necesidades?

Respuesta #1

Casi lo logras, para requerir uno pero no ambos, solo marca la propiedad never como opcional:

interface TextButtonProps extends TypedButtonProps {
  text: string
  icon?: never
}

interface IconButtonProps extends TypedButtonProps {
  icon: JSX.Element
  text?: never
}

Si TS alguna vez agrega soporte para tipos de objetos exactos como los que tiene Flow, entonces tal vez podrías obtener los errores que deseas sin las propiedades opcionales never. Pero la solicitud de función para tipos exactos ha estado abierta por mucho tiempo...

Otra opción sería crear componentes separados que deleguen en el Button subyacente:

export const IconButton = (props: IconButtonProps) => <Button {...props} />
export const TextButton = (props: TextButtonProps) => <Button {...props} />

Entonces la verificación de propiedades excesivas de TS funcionaría - en la mayoría de los casos. Es importante tener en cuenta que la verificación de propiedades excesivas no es perfecta, no garantiza la solidez como lo harían los tipos de objeto exactos.

Respuesta #2

Unión discriminada probablemente sea engañoso aquí: una unión discriminada de este tipo se basa en una propiedad discriminante, que toma un valor literal único, uno por cada tipo en la unión, haciendo así explícito qué tipo se maneja, como se muestra en la respuesta de vighnesh153.

Pero aquí no quieres tal propiedad discriminante.


¿Por qué funciona eso?

Ningún tipo es asignable a never, por lo tanto, intentar definir la prop/clave con cualquier tipo (que no sea never o undefined) es un error.

Esto efectivamente prohíbe pasar una prop/clave definida con ese nombre.


porque las propiedades son opcionales, puedo salirme con la mía sin definir ni la propiedad icon ni la propiedad text

Como se explica en la respuesta de Andy, solo las propiedades de tipo never prohibidas deberían hacerse opcionales. Puedes mantener las otras propiedades como obligatorias.


qué verboso es: si agrego más tipos de botones, tengo que agregar esas nuevas propiedades a cada interfaz.

Podemos crear algún tipo de ayuda para factorizar el código. Supongo que eso es a lo que se refiere LindaPaiste en su comentario sobre la pregunta, y les dejaré describir su propia solución, ¡ya que suena como un ayudante general interesante!

Mientras tanto, y específicamente en tu caso, primero podrías crear un tipo que prohíba todas las propiedades específicas, para que puedas usarlo en cada tipo de botón, omitiendo las propiedades específicas para que se vuelvan legales:

// List all specific props to be forbidden
type ButtonSpecificProps = 'text' | 'icon' | 'other' | 'otherCombined' | 'otherOptional'

// Mapped type to convert the list (union of literals) into forbidden keys of an object
type NeverButtonSpecificProps = {
    //^? { text?: undefined; icon?: undefined; other?: undefined; otherCombined?: undefined; otherOptional?: undefined }
    [P in ButtonSpecificProps]?: never
}

type OnlyButtonProps<T> = T // Legal specific props
    & Omit<NeverButtonSpecificProps, keyof T> // Forbidden props, except for legal ones
    & TypedButtonProps // Other common props

type TextButtonProps = OnlyButtonProps<{
    text: string
}>

type IconButtonProps = OnlyButtonProps<{
    icon: JSX.Element
}>

// You can have several legal props simultaneously, possibly some optional
type OtherButtonProps = OnlyButtonProps<{
    other: string
    otherCombined: boolean
    otherOptional?: number
}>

export const Button = ({ onClick, ...props }: IconButtonProps | TextButtonProps | OtherButtonProps) => {
    //...
    return null
}

() => (
    <>
        {/* Types of property 'text' are incompatible.
              Type 'string' is not assignable to type 'undefined'. */}
        <Button icon={<div />} text='test' uiVariant='default' />

        {/* Type '{}' is not assignable to type 'IntrinsicAttributes & (TextButtonProps | IconButtonProps | OtherButtonProps)'. */}
        <Button />

        <Button text='foo' />

        <Button icon={<div />} />

        {/* Property 'otherCombined' is missing in type '{ other: string; }' but required in type '{ other: string; otherCombined: boolean; otherOptional?: number | undefined; }'. */}
        <Button other='foo' />

        <Button other='foo' otherCombined={true} />

        <Button other='foo' otherCombined={true} otherOptional={2} />
    </>
)

Enlace del parque

Artículos relacionados