Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible to infer parent component props based on child component props

I want to make a Tabs component that infers the possible values of the active prop based on what its children have as name props. This is how I tried to do it:

import React from 'react'

interface TabProps<N extends string> {
  name: N
}

function Tab<N extends string>(props: TabProps<N>) {
  return null
}

type TabsType<N extends string = string> =
  | React.FunctionComponentElement<TabProps<N>>
  | React.FunctionComponentElement<TabProps<N>>[]

interface TabsProps<Tabs extends TabsType> {
  children: Tabs
  active: Tabs extends TabsType<infer N> ? N : unknown
}

export function Tabs<Tabs extends TabsType>(props: TabsProps<Tabs>) {
  return null
}

const test = (
  {/* I want active to be inferred as type 'bla' | 'hey' */}
  <Tabs active="blabla"> 
    <Tab name="bla" />
    <Tab name="hey" />
  </Tabs>
)

The problem here seems to be that the Tabs type parameter of the Tabs component gets inferred as JSX.Element instead of React.FunctionComponentElement<TabProps<N>>[] which has the consequence that the default type string gets used instead, making the invalid value blabla go unnoticed by typescript.

Does this have a workaround? Is this a fundamental limitation of JSX? Is this something that needs a feature request? Is this a bug?

like image 332
Stefan Wullems Avatar asked Sep 29 '20 19:09

Stefan Wullems


1 Answers

I don't think it can work the way you are imagining it, because TypeScript cannot infer that kind of type information just from the JSX children of a component. I'd be very curious if someone else has a solution that would make that work in any semblance you're trying for.

Your best bet is probably to abstract the children of <Tabs> into an object, which you can infer strict (literal) type information for. Then, using generics, you can extract the explicit (literal) name values and enforce it on the active prop. <Tabs> will simply map its data array into rendered JSX, a very common pattern in React.

import React from 'react'

function Tabs<D extends readonly {name: string}[]>({data, active}: {data: D, active: D[number]["name"]}) {
  return (
    <div>
      {data.map(ea => {
        return <div className={active === ea.name ? 'active' : ''}>{ea.name}</div>;
      })}
    </div>
  );
}

const tabData = [
  {
    name: 'bla'
  },
  {
    name: 'hey'
  }
] as const;

const test = (
  <Tabs data={tabData} active="bla" />
)

Try it on TypeScript playground. Observe that if you try and change the active prop to something other than "bla" or "hey" it will warn you.

Note the use of as const when declaring tabData. That has the benefit of allowing TypeScript to infer the explicit (literal) string value of the name properties, even though they are nested inside of an array of objects. The downside is that you can't a priori declare the type of tabData to be, e.g., an array of TabObjects, because by definition the shape of tabData must be inferred at the time of its literal declaration with as const in order to get that juicy strict string value inference.

However, you do get a useful sort of "delayed" type checking because of the way Tabs is defined: if the shape of data is invalid, <Tabs> will complain that it's not getting the expected shape for that prop.

Depending on your preferred React paradigm, you may dislike passing a data prop to <Tabs> and having it render its children based on that, instead of the composed approach you tried above. But by the nature of trying to infer types the way you are, I don't think it can be helped.

like image 145
jered Avatar answered Oct 23 '22 04:10

jered