Home > database >  Polymorphic component - selecting type from a prop in React with Typescript
Polymorphic component - selecting type from a prop in React with Typescript

Time:11-24

This is a component I'm currently working on, named TextBody

import { HTMLAttributes } from "react";
import classNames from "classnames";

interface TextBodyProps
  extends HTMLAttributes<HTMLParagraphElement | HTMLSpanElement> {
  span?: boolean;
  type: "s" | "m";
}

export const TextBody = ({
  span,
  type,
  className,
  children,
  ...props
}: TextBodyProps) => {
  const textBodyClassNames = {
    s: "text-body-s font-light leading-relaxed max-w-sm",
    m: "text-body-m font-light leading-relaxed max-w-sm",
  };

  const TextBodyElement = span ? "span" : "p";

  return (
    <TextBodyElement
      {...props}
      className={classNames(textBodyClassNames[type], className)}
    >
      {children}
    </TextBodyElement>
  );
};

Is it possible to extend HTMLAttributes<HTMLSpanElement> if span prop is passed, and only HTMLAttributes<HTMLParagraphElement> if it's not, instead of having a union?

CodePudding user response:

This is too long for a comment, and may be an answer. I would code it instead as follows:

import { HTMLAttributes } from "react";
import classNames from "classnames";

// This is a more accurate union: you have *either* the span
// attributes *or* the paragraph attributes, not a union of the
// attributes of both.
type TextBodyProps = (HTMLAttributes<HTMLParagraphElement> | HTMLAttributes<HTMLSpanElement>) & {
  span?: boolean;
  type: "s" | "m";
};

export const TextBody = ({
  span,
  type,
  className,
  children,
  ...props
}: TextBodyProps) => {
  return span ? (
    <span
      {...props}
      className={classNames("text-body-s font-light leading-relaxed max-w-sm", className)}
    >
      {children}
    </span>
  ) : (
    <p
      {...props}
      className={classNames("text-body-m font-light leading-relaxed max-w-sm", className)}
    >
      {children}
    </p>
  );
};

Is there a little bit of duplication? Yes. But a little duplication is better than premature abstraction, YAGNI, etc. That one in the original example was awkward to implement. Maybe there's an elegant way to have your cake and eat it too here, but I'd start with the simple easy-to-implement easy-to-read version first.

CodePudding user response:

This is the final solution I've come up with:

import { ElementType } from "react";
import classNames from "classnames";
import { PolymorphicComponentProps } from "../../types";

type Variants = {
  s: string;
  m: string;
};

type TextBodyProps = {
  type: keyof Variants;
};

const textBodyClassNames: Variants = {
  s: "text-body-s font-light leading-relaxed",
  m: "text-body-m font-light leading-relaxed",
};

const defaultComponent = "span";

export const TextBody = <
  Component extends ElementType = typeof defaultComponent
>({
  as,
  type,
  className,
  children,
  ...props
}: PolymorphicComponentProps<Component, TextBodyProps>) => {
  const Component = as || defaultComponent;

  return (
    <Component
      {...props}
      className={classNames(textBodyClassNames[type], className)}
    >
      {children}
    </Component>
  );
};

I think it makes it very clear how to expand it by adding a new variant, changing a variant style, or changing the default component if not specified.

PolymorphicComponentProps file contains:

import {
  ComponentPropsWithoutRef,
  PropsWithChildren,
  ElementType,
} from "react";

type AsProp<Component extends ElementType> = {
  as?: Component;
};

type PropsToOmit<
  Component extends ElementType,
  Props
> = keyof (AsProp<Component> & Props);

export type PolymorphicComponentProps<
  Component extends ElementType,
  Props = {}
> = PropsWithChildren<Props & AsProp<Component>> &
  Omit<ComponentPropsWithoutRef<Component>, PropsToOmit<Component, Props>>;
  • Related