facebook / prop-types

Runtime type checking for React props and similar objects
MIT License
4.48k stars 356 forks source link

Document best practice for using self-referencing PropTypes #316

Open posita opened 4 years ago

posita commented 4 years ago

This comment claims this use case is pretty rare, but I'm either ignorant of the alternatives, or I'm not convinced. Anywhere one wants to have a tree-like component structure, one potentially butts up against this limitation. Consider a structure involving Nodes and Leafs as follows:

function LeafComponent({ id, name }: LeafComponentT) {
  return (
    <li>{name}</li>
  )
}

LeafComponent.propTypes = {
  id: PropTypes.string.isRequired,
  name: PropTypes.string.isRequired,
}

type LeafComponentT = InferProps<typeof LeafComponent.propTypes>

NodeComponent.propTypes = {
  leaves: PropTypes.arrayOf(
    PropTypes.shape(LeafComponent.propTypes).isRequired
  ).isRequired,
}

type NodeComponentT = InferProps<typeof NodeComponent.propTypes>

function NodeComponent({ leaves }: NodeComponentT) {
  return (
    <ul>
      {leaves.map((leaf, _) => (
        <LeafComponent key={leaf.id} {...leaf} />
      ))}
    </ul>
  )
}

So far, a Node is merely a list of Leafs. Now let's say we want to augment our Node to contain zero or more entries, each of which may either be a Node or Leaf. How is this done? This won't work:

…
function recursive(...args: any) {  // 7023: 'recursive' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
  return PropTypes.arrayOf(
    PropTypes.oneOfType([
      LeafComponent.propTypes,
      PropTypes.shape({  // 2615: Type of property 'children' circularly references itself in mapped type '{ children: never; }'.
        children: recursive,
      }).isRequired,
    ])
  )(...args)  // 2556: Expected 5 arguments, but got 0 or more.
}

function NodeComponent({ children }: NodeComponentT) {
  return (<ul>{
    children.map((child, _) => {
      if (child instanceof Object
        && "children" in child) {
        const node_child = child as NodeComponentT
        return (<li><NodeComponent children={node_child.children} /></li>)
      } else {
        const leaf_child = child as LeafComponentT
        return (<LeafComponent key={leaf_child.id} {...leaf_child} />)
      }
    })
  }</ul>)
}

NodeComponent.propTypes = {
  children: PropTypes.arrayOf(recursive).isRequired,
}

type NodeComponentT = InferProps<typeof NodeComponent.propTypes>
…

You'll get a circular reference type error. So how should one handle this case in the real world? I can find near zero documentation on how to do this, either here or via the React docs. Any guidance would be appreciated.

ljharb commented 4 years ago

I agree that circular propTypes are needed for a tree-like structure that allows circularity, but that specific need is indeed exceedingly rare.

You'd handle this by using a custom propType to wrap the recursion, rather than actually using recursion.

posita commented 4 years ago

To clarify, what I'm pointing out by filing this issue is that no clear guidance exists to address this pattern. I've clearly gotten it wrong here, but that's largely because I've been feeling around in the dark absent such guidance.

You'd handle this by using a custom propType to wrap the recursion, rather than actually using recursion.

☝️ Thanks, but I don't understand what that means or whether it differs from your prior recommendation on #246. Can you point to a clear example?


Right now, I've constructed the following work-around:

type LeafComponentT = InferProps<typeof LeafComponent.propTypes>

function LeafComponent({ id, name }: LeafComponentT) {
  return (
    <span>{name} ({id})</span>
  )
}

LeafComponent.propTypes = {
  id: PropTypes.string.isRequired,
  name: PropTypes.string.isRequired,
}

interface RootComponentT {
  children: NodeComponentT[],
}

function RootComponent({ children }: RootComponentT) {
  if (children
    && children.length > 0) {
    return (
      <ul>
        {children.map((child, _) => (
          <NodeComponent {...child} />
        ))}
      </ul>
    )
  } else {
    return null
  }
}

RootComponent.propTypes = {
  children: PropTypes.array.isRequired,
}

type NodeComponentT = RootComponentT & {
  leaf: InferProps<typeof LeafComponent.propTypes>,
}

function NodeComponent({ leaf, children }: NodeComponentT) {
  return (
    <li>
      <LeafComponent key={leaf.id} {...leaf} />
      <RootComponent children={children} />
    </li>
  )
}

NodeComponent.propTypes = {
  leaf: PropTypes.shape(LeafComponent.propTypes).isRequired,
  children: PropTypes.array.isRequired,
}

I call this a work-around because there is no runtime type checking of the elements of children.