Fixing generics in React
While plain components support generic props out of the box, wrappers like ‘forwardRef’, ‘lazy’ and ‘memo’ are cumbersome in that regard.
Let’s suppose we’re building a custom
<select>. It should accept a
value of any kind, dealing with serialization under the hood:
(The implementation is a rough draft on purpose.)
Proceeding further, we may want to expose the underlying DOM node via
forwardRef, e.g. for focus management:
In this case, however,
T is always inferred to be
Select has lost type information, so neither of the following works as expected:
Delving into the type definitions for React, we find ourselves with tons of declarations. Seeking through its code, we stumble upon:
There’s so much going on here. Let’s break it down step-by-step:
- One of the arguments, namely
render, takes a function. Therefore,
forwardRefis a higher-order function.
- The return type is a component definition, as hinted by the name
reftype is always inferred from the DOM node type
T. If the props type
refkey, that’s ignored.
Since TypeScript 3.4, higher-order type inference enables retaining type parameters of generic function signatures.
Types highlighted in the previous snippet accept functions:
Apparently, type argument inference works only on function types having a single non-generic call signature and no other members.
Neither of the declarations meets those criteria. They’re function types with other members.
We could get rid of such bits, though, leaving us with:
And that does the trick
With these changes in place,
forwardRef would handle type parameters correctly.
On the other hand, we got rid of some public properties (all except
$$typeof) inadvertently. Luckily, each of them has a substitute:
displayNameis redundant for named functions, including the ones starting like
defaultPropswill be deprecated in favor of default value assignment for destructured props.
propTypesfunctionality is superseded by TypeScript.
Also, notice that the resulting interfaces may be specified inline by rewriting them as function type literals:
ReturnType<FunctionComponent> is now used over
ReactNode for compatibility with
typescript@<5.1. Otherwise, those versions would need
ReactElement | null.
@types/react package sounds fairly complex. Fortunately, we have more robust techniques to choose from.
Working on a standalone application, module augmentation can be leveraged to patch the affected types.
Just add the following to a
.d.ts file and we’re set:
While the original function signatures remain intact, our overloads take priority for being more specific.
When using generics, call those instead of React’s built-ins, like:
lazy can’t retain its generics by nature. It takes a
Promise or another thenable as its parameter. Both must have a
then member, violating the type argument inference criteria mentioned earlier.
Honestly, I’d be happy if this post became obsolete.
Just as it is with styling deviations across browsers, we should strive to resolve the root cause of issues.
Thinking that way, I see the following prospects:
- A library like TS Reset could simplify adopting fixes within apps.
- If React would remove static properties like
propTypes, the official typings could be refactored to support type argument inference.
forwardRefmay become deprecated.
memomay be made redundant by the React Forget compiler, alongside
- Last, but not least, TypeScript’s inference logic might improve.
Closing with a quote from the React Core team member Dan Abramov:
[We] often need to add before we can remove. Stepping stones.
My key takeaway is not to be afraid of experimenting. Ultimately, that’s how our world evolves.