Behind the ‘as’ prop: polymorphism done well
A button renderable as a link has quirky types in React. How could custom tags be assigned to such components?
Reusable bits of a site mostly have rigid HTML semantics. Some of those constraints are unwarranted, though.
Starting with a simple button, we may end up using it for navigation:
Despite acting okay, this pattern has major flaws. Above all, we broke the first rule of ARIA:
If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.
Abiding that, a link will suffice our needs:
Styling may be borrowed from within MyButton
, just as embraced by Class Variance Authority. However…
CSS has its limits
Many accessible states can only be exposed via HTML. For instance, a toggle button should utilize the aria-pressed
attribute, unless its label changes when clicked.
No wonder component-oriented separation of concerns feels so natural.
Encapsulation of behavior
Our goal is to make MyButton
render an <a>
element when desired, falling back to <button>
in the absence of an as
prop.
In JSX, lowercase tags like <a>
refer to built-in HTML/SVG elements. Conversely, components have their first letter capitalized:
This gets transpiled into a function call behind the scenes:
We may pass variables along to that, e.g. for the type:
Yet we can’t follow suit in JSX because as
is lowercase, implying a built-in element:
How might we tell JSX otherwise? Just use a PascalCase variable:
That idea forms the basis for our polymorphic component:
So far so good. The biggest hurdle is yet to come, though.
Adding types
Let’s begin from the inside out:
It’s tempting to use React.ElementType<P = any>
as is. However, that goes over all the built-in elements, keeping the ones taking P
as attributes.
Luckily, we can narrow the set of tags endorsed, reducing type instantiations by an order of magnitude:
(A tag-independent abstraction should likely be refactored into a hook at this point.)
At last, it’s time to declare the props of MyButton
:
- With discriminated unions in mind, we merge the
as
prop using a distributive conditional type sinceOmit<T, K>
only retains properties common to every object in theT
union. - Components rendered by
MyButton
may not require anas
prop because that can’t be passed along from outside.P & { as?: never }
guards against suchP
types.
This concludes our journey towards creating a type-safe polymorphic component in React.
While type assertions are bad practice in TypeScript, we may either:
- Make the
as
prop required, undermining convenience. - Define
const Element = as ?? "button"
and remove the assignment fromas = "button"
. This results in bogus auto-completion forElement
props. - Assume the default value
"button"
is assignable toT
, even though setting the type argument explicitly like<MyButton<"a">>
voids that.
I’ve opted for the latter but ultimately, it’s a matter of personal choice. Life is never black and white, hence being full of beauty 🌈