2

I'm trying to create a component that takes an onClick OR a to prop.

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>
  )
}

I get the following TS error.

TS2339: Property 'to' does not exist on type 'PropsWithChildren({ onClick: MouseEventHandler ; } | { to: string; }) & { buttonComponent?: ({ onClick: MouseEventHandler }: { onClick: any; }) => Element; } & BoxProps>'.

Here is a simplified TS playground

I've tried:

  • removing the React.FC type and simply typing the deconstructed props object -- same result
  • Removing all the other props besides onClick and to -- { onClick: MouseEventHandler } | { to: string } -- then both those props get the same kind of error

I also tried some more "fully defined" union types:

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

interface LinkProps extends BasicProps {
  to: string
}

interface ButtonProps extends BasicProps {
  onClick: MouseEventHandler
}

but I get the same result!

I hope it's not just my TS compiler being weird/slow 🙃

11
  • Syntax is off Commented Dec 7, 2022 at 18:26
  • Your inlined generic parameter seems to be malformed when I pull it out into a type, and I get a compiler error about it. Commented Dec 7, 2022 at 18:27
  • @JaredSmith can you be more specific? How/where is my syntax off? I'm not sure I understand your second comment, do you mean the "type parameter" to React.FC? I tried pulling that out and it's not giving me any errors. Commented Dec 7, 2022 at 18:32
  • 1
    I've slightly modified your playground. Note that to in the function body is an error, and that foo is allowed to have both to and onClick Commented Dec 7, 2022 at 20:01
  • 1
    @JonathanTuzman please provide playground link. Your link is broken Commented Dec 9, 2022 at 11:13

2 Answers 2

1

This ended up being long enough to warrant another answer completely, see my other one for explanation on why the original use case throws an error.


To get the either-or functionality you're going for, you'll need to specify to TypeScript that a known type is not allowed when another known type is specified. To do this, you'll need the never type:

The never type is assignable to every type; however, no type is assignable to never (except never itself)

In practice, this will look like1:

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

Now when assigning a value of type 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 }

Now you can use these properties like so:

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

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

What exactly this technique would look like in your React example, I'll leave that as an exercise to you.


1 Notice how the never properties are optional; if they weren't, typescript would require you to specify them but at the same time you couldn't define any value for those properties since no value is of type never, leaving you in a gridlock.

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks Emma, this is beautiful! I wound up writing a type guard function (returning is LinkProps and once I use that function to check, TS is satisfied. But I love your answer.
1

Seems to me that everything is working as intended. TypeScript's structural type system will only check that required properties exist, not that extra properties don't:

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);

Something that might be confusing is TypeScript's concept of freshness, also known as strict object literal checking. With freshness checks TypeScript will in fact check that object literals only define known properties:

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)

What's confusing here is the separation of known and "extra" properties. With type unions, there might be properties that are known but 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)

What your problem boils down to is that TypeScript cannot guarantee that the property you're trying to destructure actually exists:

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;

But when the type system can guarantee that a property exists in all the parts of the union, you can safely destructure that property:

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;

Now to look at your exact problem, TypeScript is telling you what the problem is, although a bit too verbosely:

TS2339: Property 'to' does not exist on type 'PropsWithChildren({ onClick: MouseEventHandler ; } | { to: string; }) & { buttonComponent?: ({ onClick: MouseEventHandler }: { onClick: any; }) => Element; } & BoxProps>'.

As StickyButton is a FC<Props>, by the definition of FC<Props>, it is a function that accepts an argument of type Props. In your case, type Props equals the word salad after "does not exist on type" in the error message, and TypeScript cannot guarantee that a property called to will always exist in it. Thus it cannot be destructured in the function argument:

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

2 Comments

Thanks for your great answer, @Emma. Is there a way to declare things that will actually allow me to use that union type in the way I'm attempting? Where the component's props include to OR onClick?
@JonathanTuzman No worries! And sure; in general you could do a discriminated union, although that's not the best for developer experience in this case. In a strict either-or situation like this, what you'll want to do is to block the unwanted property by using the never type. I'll amend my answer to showcase this; give me a second.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.