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 unknown
.
The outer Select
has lost type information, so neither of the following works as expected:
How could we mitigate the issue?
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,forwardRef
is a higher-order function. - The return type is a component definition, as hinted by the name
ForwardRefExoticComponent
. - The
ref
type is always inferred from the DOM node typeT
. If the props typeP
has aref
key, 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:
displayName
is redundant for named functions, including the ones starting likefunction Component(props)
.defaultProps
will be deprecated in favor of default value assignment for destructured props.propTypes
functionality 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 @types/react@<18.2.8
and typescript@<5.1
. Otherwise, those versions would need ReactElement | null
.
Applying the fix in practice
Patching the @types/react
package sounds fairly complex. Fortunately, we have more robust techniques to choose from.
Within apps
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.
Within libraries
Packages can’t augment app-level type declarations implicitly unless their name starts with @types/
. We can redeclare values with our own type assertions, though:
When using generics, call those instead of React’s built-ins, like:
What about lazy components?
Unfortunately, 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.
The future
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
displayName
,defaultProps
andpropTypes
, the official typings could be refactored to support type argument inference. forwardRef
may become deprecated.- Eventually,
memo
may be made redundant by the React Forget compiler, alongsideuseMemo
anduseCallback
. - Last, but not least, TypeScript’s inference logic might improve.
Wrapping up
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.