reactjs - Typescript acceptera inte unionstyp i komponentdeklaration


Jag försöker skapa en komponent som tar en onClick ELLER en to-egenskap.

const StickyButton: React.FC<
  ({ onClick: MouseEventHandler } | { to: string }) & {
    buttonComponent?: ({ onClick: MouseEventHandler }) => JSX.Element
  } & BoxProps
> = ({
  children,
  onClick,
  to,
  buttonComponent: ButtonComponent = Button,
  ...props
}) => {
  const handler = !!to ? () => navigate(to) : onClick

  return (
    <StickyBox {...props}>
      <ButtonComponent onClick={handler}>{children}</ButtonComponent>
    </StickyBox>
  )
}

Jag får följande TS-fel.

TS2339: Egenskapen 'to' finns inte på typen 'PropsWithChildren({ onClick: MouseEventHandler; } | { to: string; }) & { buttonComponent?: ({ onClick: MouseEventHandler }: { onClick: any; }) => Element; } & BoxProps>'.

Här är en förenklad TS-lekplats

Jag har försökt:

  • ta bort React.FC-typen och bara skriva typ för det dekonstruerade props-objektet -- samma resultat
  • Att ta bort alla andra props förutom onClick och to -- { onClick: MouseEventHandler } | { to: string } -- då får båda dessa props samma typ av fel

Jag försökte också några fler "fullständigt definierade" unionstyper:

interface BasicProps extends BoxProps {
  buttonComponent?: ({ onClick: MouseEventHandler }) => JSX.Element
}

interface LinkProps extends BasicProps {
  to: string
}

interface ButtonProps extends BasicProps {
  onClick: MouseEventHandler
}

men jag får samma resultat!

Jag hoppas att det inte bara är min TS-kompilator som beter sig konstigt/långsamt 🙃

Svar #1

Detta blev tillräckligt långt för att motivera ett helt separat svar, se mitt andra för en förklaring på varför det ursprungliga användningsfallet orsakar ett fel.


För att få den antingen-eller-funktionalitet du siktar på, behöver du specificera för TypeScript att en känd typ inte är tillåten när en annan känd typ är specificerad. För att göra detta behöver du typen never:

Den never-typen kan tilldelas varje typ; däremot kan ingen typ tilldelas never (utom never själv)

I praktiken kommer detta att se ut som1:

type FooA = { a: number, b?: never }
type FooB = { b: number, a?: never }
type Foo = FooA | FooB

Nu när du tilldelar ett värde av typen Foo:

const foo1: Foo = { a: 0 } // Works
const foo2: Foo = { b: 0 } // Works

/*
 * Type error: foo3 cannot be `FooA` since `a` isn't defined
 * but it also cannot be `FooB` since `b` isn't specified!
 */
const foo3: Foo = {}

/*
 * Type error: foo4 cannot be `FooA` since `b` has a value
 * but it also cannot be `FooB` since `a` has a value!
 */
const foo4: Foo = { a: 0, b: 0 }

Nu kan du använda dessa egenskaper så här:

function useFoo(foo: Foo) {
  const { a } = foo; // a: number | undefined
  const { b } = foo; // b: number | undefined

  const n = foo.a ?? foo.b; // n: number
}

Hur exakt denna teknik skulle se ut i ditt React-exempel, lämnar jag som en övning åt dig.


1 Observera hur never-egenskaperna är valfria; om de inte var det skulle TypeScript kräva att du specificerar dem men samtidigt skulle du inte kunna definiera något värde för dessa egenskaper eftersom inget värde har typen never, vilket skulle sätta dig i ett dödläge.

Svar #2

Det verkar för mig som att allt fungerar som avsett. TypeScripts strukturella typsystem kommer bara att kontrollera att nödvändiga egenskaper finns, inte att extra egenskaper inte finns:

type Foo = { bar: string }
function doSomething(thing: Foo) { … }

// Not explicitly a `Foo`
const foo = { bar: "barbarbar", baz: "bazbazbaz" }

// This is allowed since the type of `foo` extends `Foo`
doSomething(foo);

Något som kan vara förvirrande är TypeScripts koncept av freshness, även känt som strikt kontroll av objektliteraler. Med freshness-kontroller kommer TypeScript faktiskt att kontrollera att objektliteraler endast definierar kända egenskaper:

type Foo = { bar: string }
function doSomething(thing: Foo) { … }

// Allowed, only known properties defined:
const aFoo: Foo = { bar: "barbarbar" }
// This is also, by necessity, allowed:
doSomething({ bar: "barbarbar" })

// Not allowed with strict object literal checking:
const bFoo: Foo = { bar: "barbarbar", baz: "baz" }
//                                    ~~~~~~~~~~
//                                    Type '{ bar: string; baz: string; }' is not assignable to type 'Foo'.
//                                    Object literal may only specify known properties, and 'baz' does not exist in type 'Foo'.
//                                    (2322)

// Also not allowed:
doSomething({ bar: "barbarbar", baz: "baz" })
//                              ~~~~~~~~~~
//                              Argument of type '{ bar: string; baz: string; }' is not assignable to parameter of type 'Foo'.
//                              Object literal may only specify known properties, and 'baz' does not exist in type 'Foo'.
//                              (2345)

Det som är förvirrande här är separationen av kända och "extra" egenskaper. Med typunioner kan det finnas egenskaper som är kända men extra:

type A = { a: string }
type B = { b: string }

type U = A | B

// Both allowed:
const aFoo: U = { a: "foo" }
const bFoo: U = { b: "bar" }

// But so is this, as all properties are known ones,
// even though one of them is not strictly needed to fit the shape of the type:
const cFoo: U = { a: "foo", b: "bar" }

// But this will fail with the extra property `c`
const dFoo: U = { a: "foo", b: "bar", c: "baz" }
//                                    ~~~~~~~~
//                                    Type '{ a: string; b: string; c: string; }' is not assignable to type 'U'.
//                                    Object literal may only specify known properties, and 'c' does not exist in type 'U'.
//                                    (2322)

Vad ditt problem kokar ner till är att TypeScript inte kan garantera att egenskapen du försöker destrukturera faktiskt existerar:

type A = { a: string }
type B = { b: string }

type U = A | B

// This is allowed, since all properties are known
const foo: U = { a: "foo", b: "bar" }

// These fail, since the type information cannot guarantee the `a` or `b` properties exist on U:
// By the definition of U, `foo` is guaranteed to have at least all the properties of either `A` or `B`,
// but if `foo` extends `B`, it's not guaranteed to have `a` and vice versa
const { a } = foo;
const { b } = foo;

Men när typ-systemet kan garantera att en egenskap finns i alla delar av unionen, kan du säkert destrukturera den egenskapen:

type A = { a: string, x: number }
type B = { b: string, x: number }

type U = A | B
const foo: U = { a: "foo", b: "bar", x: 0 }

// These fail as per above
const { a } = foo;
const { b } = foo;

// This will work, as `x` is guaranteed to exist on all values of type `U`
const { x } = foo;

Nu för att titta på ditt exakta problem, TypeScript berättar för dig vad problemet är, även om det är lite för detaljrikt:

TS2339: Egenskapen 'to' finns inte på typen 'PropsWithChildren({ onClick: MouseEventHandler ; } | { to: string; }) & { buttonComponent?: ({ onClick: MouseEventHandler }: { onClick: any; }) => Element; } & BoxProps>'.

Eftersom StickyButton är en FC<Props>, enligt definitionen av FC<Props>, är det en funktion som tar ett argument av typen Props. I ditt fall motsvarar typen Props ordsalladen efter ">does not exist on type" i felmeddelandet, och TypeScript kan inte garantera att en egenskap kallad to alltid kommer att finnas i den. Därför kan den inte destruktureras i funktionsargumentet:

const StickyButton: React.FC<…> = ({
  children,
  onClick,
  to, // This might not exist
  buttonComponent: ButtonComponent = Button,
  ...props
}) => { … }