How to merge JSX component props in Typescript

October 26, 2021

Let's say you have some wrapper component for input with some logic inside, and you want to accept props only for the specified input. What can you do? You can use ComponentPropsWithoutRef (prefer ComponentPropsWithRef, if the ref is forwarded) to mirror every prop of the specified element (tag).

index.tsx
1type CustomInputProps = React.ComponentPropsWithoutRef<'input'>
2
3const CustomInput = (props: CustomInputProps) => {
4 // ...
5 return <input {...props} />
6}
7
8export default function App() {
9 return (
10 <main>
11 <CustomInput />
12 </main>
13 )
14}

Extending CustomInputProps with a new prop is straightforward using intersections:

index.tsx
1type CustomInputProps = React.ComponentPropsWithoutRef<'input'> & {
2 customProp: string
3}
4
5const CustomInput = ({ customProp, ...props }: CustomInputProps) => {
6 // ...
7 return <input {...props} />
8}
9
10export default function App() {
11 return (
12 <main>
13 <CustomInput customProp="" />
14 </main>
15 )
16}

But how about replacing existing props? To give an example, what if we want to make the default name prop required because our custom logic depends on it? We can use intersection as well:

index.tsx
1type CustomInputProps = React.ComponentPropsWithoutRef<'input'> & {
2 name: string
3}
4
5const CustomInput = ({ ...props }: CustomInputProps) => {
6 // ...
7 return <input {...props} />
8}
9
10export default function App() {
11 return (
12 <main>
13 <CustomInput name="" />
14 </main>
15 )
16}

But for me, it is not explicit enough. Currently, our CustomInput component accepts more than 280 props inferred from the input element. Can you say if the prop you have specified (in our case name) should be overridden or not? In some cases yes, in some no. I would like to introduce an alternative approach.

What if we omit the specified prop and then extend it with our custom one?

index.tsx
1type CustomInputProps = Omit<
2 React.ComponentPropsWithoutRef<'input'>,
3 'name'
4> & {
5 name: string
6}
7
8const CustomInput = ({ ...props }: CustomInputProps) => {
9 // ...
10 return <input {...props} />
11}
12
13export default function App() {
14 return (
15 <main>
16 <CustomInput name="" />
17 </main>
18 )
19}

There is no difference in the functionality, but our type is more explicit. We construct a type by picking all properties from elements and then removing keys we do not want.

What could we do better? I would say that some utility type with generics would be great.

index.tsx
1export type MergeComponentProps<
2 ElementType extends React.ElementType,
3 Props extends object = {}
4> = Omit<React.ComponentPropsWithoutRef<ElementType>, keyof Props> & Props
5
6type CustomInputProps = MergeComponentProps<
7 'input',
8 {
9 name: string
10 }
11>
12
13const CustomInput = ({ ...props }: CustomInputProps) => {
14 // ...
15 return <input {...props} />
16}
17
18export default function App() {
19 return (
20 <main>
21 <CustomInput name="" />
22 </main>
23 )
24}

Now let me explain our helper type with generics:

We've added a type variables ElementType that extends React.ElementType and Props which we initialize as empty object. Then we omit Props keys from the specified Element passed into ComponentPropsWithRef. And the last step is applying the Props object using intersections into our type.

I prefer using the MergeComponentProps type because it is more explicit than a simple intersection. We can see from the first sight that we are merging props, not creating a new one, and we can be sure that the original prop was removed from the type.

Share this post on Twitter