Introduction

Introduction

As TypeScript continues to grow in popularity and strength, becoming an indispensable part of improving the library development experience, developers who are accustomed to TypeScript often feel something is missing without type definitions, much like navigating an unmarked intersection. React, on the other hand, has long held a prominent place in frontend development, with its simple design and close alignment with native JavaScript. Its flexibility and extensibility are key strengths. Today, let’s explore what sparks might fly when TypeScript and React come together. Our goal is to make the components reusable, flexible, extendable, and type-safe while ensuring a good developer DX with TypeScript-integrated editors. This article is ideal for those who are familiar with TypeScript and React and enjoy designing shared components or implementing design systems to enhance the development experience while maintaining flexibility.

Grouping Components

Generally, we group related components into a single file or folder to make it easier to use and understand how they should or can be used together:

import { Select } from '@component/Select';
import { Option } from '@component/Select/Option';

<Select>
  <Option value="1">Option 1</Option>
  <Option value="2">Option 2</Option>
  <Option value="3">Option 3</Option>
</Select>        

We can group components in to a single instance to make it more easier to use:

import { Select } from '@component/Select';

<Select>
  <Select.Option value="1">Option 1</Select.Option>
  <Select.Option value="2">Option 2</Select.Option>
  <Select.Option value="3">Option 3</Select.Option>
</Select>        

For function components, we can assign the components to a property of the main component:

import type { ReactNode } from "react";

const Select = ({ children }: { children?: ReactNode }) => {
  return <select>{children}</select>;
};

const Option = ({
  value,
  children,
}: {
  value?: string;
  children?: ReactNode;
}) => {
  return <option value={value}>{children}</option>;
};

Select.Option = Option;

export { Select };        

However, in most cases, we use the React.FC type to validate the function component implementation. This approach does not allow us to assign components as properties of the main component.

import type { FC, ReactNode } from "react";

const Select: FC<{ children?: ReactNode }> = ({ children }) => {
  return <select>{children}</select>;
};

const Option: FC<{
  value?: string;
  children?: ReactNode;
}> = ({ value, children }) => {
  return <option value={value}>{children}</option>;
};

Select.Option = Option;
//     ^^^^^^ 
//     Property 'Option' does not exist on type 'FC<{ children?: ReactNode;
//     }>'.ts(2339)

export { Select };        

To solve this, we use Object.assign to assign the components to the main component:

import type { FC, ReactNode } from "react";

const SelectInternal: FC<{ children?: ReactNode }> = ({ children }) => {
  return <select>{children}</select>;
};

const Option: FC<{
  value?: string;
  children?: ReactNode;
}> = ({ value, children }) => {
  return <option value={value}>{children}</option>;
};

const Select = Object.assign(SelectInternal, { Option });

export { Select };        

We can also assign various related elements to it, including hooks, styles, class names, default values, and more.

Note that when using React 18 or earlier, we need to manually forward the ref. Similarly, related utilities can be assigned using Object.assign.

const ButtonInternal = forwardRef<ButtonRef, ButtonProps>((props, ref) => {
  return <button {...props} ref={ref} />;
});

const Button = Object.assign(ButtonInternal, {
  // ...
});        

Grouping Types

Similarly to how we handle runtime components, we can also group related types within the component to make them easier to use:

import { Button } from "./Select";

const buttonProps: useMemo<Button.Props>(() => ({
  variant: "primary",
}), []);

<Button {...buttonProps} />;        

To implement this, we can use the namespace keyword:

import type { FC, ReactNode } from "react";

namespace Button {
  export interface Props {
    variant?: "primary" | "secondary"
    children?: ReactNode;
    onClick?: () => void;
  }
}

const ButtonInternal = forwardRef<Button.Ref, Button.Props>((props, ref) => {
  return <button {...props} ref={ref} />;
});

const Button = Object.assign(ButtonInternal, {
  defaultRootElement,
});

export { Button };        

Inheritance and Extension

Inherit the base component to create a new component:

namespace Button {
  export interface Props extends ComponentProps<"button"> {}
}        

And now we can use all the props from the base component without redefining them.

Notice that when using React 18 or earlier, we have to forward the ref manually:

namespace Button {
  export type Ref = ElementRef<"button">;
  export interface Props extends ComponentProps<"button"> {}
}

const ButtonInternal = forwardRef<Button.Ref, Button.Props>((props, ref) => {
  return <button {...props} ref={ref} />;
});

const Button = Object.assign(ButtonInternal, {
  // ...
});        

We can use default props and override props to extend the functionality of the component:

<select {...defaultProps} {...props} {...overrideProps} />        

To change the default props of a component, we can use defaultProps to override the existing defaults. For example, if we want to set the multiple attribute of a select element as the default, we can use the following code:

const SelectInternal: FC<Select.Props> = (props) => {
  // ...
  return <select multiple {...props} {...overrideProps} />;
}

const Select = Object.assign(SelectInternal, {
  // ...
});        

Then we can easily turn it off by setting the multiple attribute to false:

<Select multiple={false} />        

Alternatively, if we want to create a <MultiSelect /> component that always has the multiple attribute, we can use overrideProps like this:

const MultiSelectInternal: FC<Select.Props> = (props) => {
  // ...
  return <select {...props} {...overrideProps} multiple />;
}

const MultiSelect = Object.assign(MultiSelectInternal, {
  // ...
});        

Since the multiple prop should no longer be available for this component, remember to omit it from the Select.Props:

namespace MultiSelect {
  export interface Props extends Omit<ComponentProps<"select">, "multiple"> {}
}        

Note that we don’t always “override” props; sometimes we simply “extend” them. For example, if there is an internal onClick handler or className that we want to retain, we need to extend the props rather than override them:

const onClick = useEventCallback<
  NonNullable<ComponentProps<"select">["onClick"]>
>((e) => {
  props.onClick?.(e);
  if (e.defaultPrevented) return;
  // Do something
});

const className = useMemo<ComponentProps<"select">["className"]>(() => {
  return clsx(
    {
      "select-primary": variant === "primary",
      "select-secondary": variant === "secondary",
    },
    props.className
  );
}, [props.className]);

return (
  <select
    {...defaultProps}
    {...props}
    onClick={onClick}
    className={className}
  />
);        

When extending MUI components, we can style them using the sx prop like this:

// Outside the component
const sxRecord = {
  selectRoot: {
    backgroundColor: "red",
  },
} satisfies Record<string, SxProps>;

// In the component
const sx = useMemo<ComponentProps<typeof Select>["sx"]>(
  // Merge the styles
  () => [sxRecord.selectRoot, props.sx],
  [props.sx],
);
return <Select {...props} sx={sx} />;        

Other CSS-in-JS libraries can use similar methods to compose styles within a component. Remember to place external styles after internal styles to ensure that internal styles are overridden by external styles.

// @emotion/css
const className = useMemo<Props["className"]>(() => cx(styles.root, props.className), [props.className]);        

We don’t need to manually merge className with the Emotion CSS prop (@emotion/react) because Emotion handles it automatically.

Inferring and Validating Component Props

We can use TypeScript generics to validate the component props:

namespace Select {
  export interface Props<TValue>
    extends Omit<ComponentProps<"select">, "value" | "onChange" | "multiple"> {
    value?: TValue;
    onChange?: (value: TValue) => void;
  }
  export interface Type {
    <TValue>(props: Props<TValue>): ReactNode;
  }

  export interface OptionProps<TValue>
    extends Omit<ComponentProps<"option">, "value"> {
    value?: TValue;
  }

  export interface OptionType {
    <TValue>(props: OptionProps<TValue>): ReactNode;
  }
}        

Define the component with the generic type, and we can declare the typed components:

const values = ["foo", "bar", "baz"] as const;
type Value = (typeof values)[number];
const TypedSelect = Select<Value>;
const TypedOption = Select.Option<Value>;

function Demo() {
  return (
    <TypedSelect>
      <TypedOption value="foo">foo</TypedOption>
      <TypedOption value="bar">bar</TypedOption>
      <TypedOption value="baz">baz</TypedOption>
      <TypedOption value="qux">qux</TypedOption>
      {/*          ^^^^^ Type '"qux"' is not assignable to type '"foo" | "bar" | "baz"'.ts(2322) */}
    </TypedSelect>
  );
}        

Back in the real world, name the typed components with what they are, for example, CategorySelect and CategoryOption.

You can also infer types from the props passed to a component. For instance, if you have a Field component that accepts a path prop, you can infer the type of the path prop and use that to infer the type of the rules prop. Here’s an example:

namespace Field {
  export interface Values {
    id: number;
    name: string;
    weight: number;
    height: number;
  }
  export interface Props<Key extends keyof Values> {
    path: Key;
    rules?: Array<{
      test: (value: Values[Key]) => boolean;
      message: string;
    }>;
    // ...More props here.
  }
  export interface Type {
    <Key extends keyof Values>(props: Props<Key>): null;
  }
}

const Field: Field.Type = () => ...

<Field
  path="name"
  rules={[
    {
      test: (value) => value.length > 0,
      //     ^^^^^ Inferred as string from `path="name"`
      message: "Name is required",
    },
  ]}
/>        

Multiple Call Signatures

We can define multiple call signatures to provide different patterns for using the component. For example, we can offer a children prop or an options prop for the <Select /> component.

namespace Select {
  export interface PropsWithChildren<TValue>
    extends Omit<ComponentProps<"select">, "value" | "onChange" | "multiple"> {
    value?: TValue;
    onChange?: (
      value: TValue,
      e: Parameters<NonNullable<ComponentProps<"select">["onChange"]>>[0],
    ) => void;
  }
  export interface PropsWithOptions<TValue>
    extends Omit<PropsWithChildren<TValue>, "children"> {
    options?: Array<
      | (TValue & Key)
      | (OptionProps<TValue> & {
          key: Key;
        })
    >;
  }
  export interface Type {
    <TValue>(props: PropsWithChildren<TValue>): ReactNode;
    <TValue>(props: PropsWithOptions<TValue>): ReactNode;
  }
}

<Select options={[1, 2]} /> // ? Valid

<Select>
  <Select.Option value={1}>One</Select.Option>
  <Select.Option value={2}>Two</Select.Option>
</Select> // ? Valid

<Select options={[1, 2]}>
  <Select.Option value={1}>One</Select.Option>
  <Select.Option value={2}>Two</Select.Option>
</Select> // Error: Property 'options' does not exist on type ...        

Overridable Root Element

To enhance the flexibility of components, we offer a component prop, similar to MUI, or the as prop found in Chakra UI:

<Button component="a" >
  Crescendo Lab
</Button>        

Before we begin implementing this, we need to introduce these type utilities borrowed from MUI:

/**
 * Remove properties `K` from `T`.
 * Distributive for union types.
 */
type DistributiveOmit<T, K extends keyof any> = T extends any
  ? Omit<T, K>
  : never;

/**
 * Like `T & U`, but using the value types from `U` where their properties overlap.
 */
type Overwrite<T, U> = DistributiveOmit<T, keyof U> & U;        

It’s a good idea to install @mui/types for reusability.

We can define the interface using multiple generic types as the component type:

import type { ComponentProps, ElementType, ReactNode } from "react";

const defaultRootElement = "button" as const;

namespace Button {
  export interface OwnProps {
    // Own props here.
    variant?: "primary" | "secondary";

    /**
     * Override the default root element.
     */
    component?: ElementType;
  }
  export type Props<
    TRootElement extends ElementType = typeof defaultRootElement,
  > = Overwrite<ComponentProps<TRootElement>, OwnProps>;
  export interface Type {
    <TRootElement extends ElementType = typeof defaultRootElement>(
      props: Overwrite<Props<TRootElement>, { component: TRootElement }>,
    ): ReactNode;
    (props: DistributiveOmit<Props, "component">): ReactNode;
  }
}

const ButtonInternal: Button.Type = ({
  component: Component = defaultRootElement,
  variant,
  ...props
}: Button.OwnProps) => {

  // ...Internal logic here.

  return <Component {...props} />;
};

const Button = Object.assign(ButtonInternal, {
  defaultRootElement,
});

export { Button };        

Now we can use the component prop to override the root element:

<Button
  component="a"
  
  onClick={(e) => e}
  //        ^? React.MouseEvent<HTMLAnchorElement, MouseEvent>
  ref={(e) => console.log(e)}
  //    ^? HTMLAnchorElement
/>        

We can also use component instead of html element:

<Button component={Link} to="/" />        

In React 18 or earlier, we need to manually forward the ref, and the type will look like this:

export interface Type {
  <TRootElement extends ElementType = typeof defaultRootElement>(
    props: DistributiveOmit<Props<TRootElement>, "component"> & {
      component: TRootElement;
    },
    ref?: ElementRef<TRootElement>,
  ): ReactNode;
  (
    props: Omit<Props, "component">,
    ref?: ElementRef<typeof defaultRootElement>,
  ): ReactNode;
}        

To verify the component prop, for instance, if we want to ensure its className and onClick can be extended as in the example above, we can declare a BaseRootElementType to replace ElementType in the overridable component:

namespace Button {
  export interface BaseRootElementProps {
    className?: undefined | string;
    onClick?: undefined | React.MouseEventHandler<HTMLElement>;
  }

  export type BaseRootElementType = ElementType<BaseRootElementProps>;

  export interface OwnProps {
    // Own props here.
    variant?: "primary" | "secondary";

    /**
     * Override the default root element.
     */
    component?: BaseRootElementType;
  }
  export type Props<
    TRootElement extends BaseRootElementType = typeof defaultRootElement,
  > = Overwrite<ComponentProps<TRootElement>, OwnProps>;
  export interface Type {
    <TRootElement extends BaseRootElementType = typeof defaultRootElement>(
      props: Overwrite<Props<TRootElement>, { component: TRootElement }>,
    ): ReactNode;
    (props: DistributiveOmit<Props, "component">): ReactNode;
  }
}

const ButtonInternal: Button.Type = ({
  component: Component = defaultRootElement,
  variant,
  ...props
}: Overwrite<Button.BaseRootElementProps, Button.OwnProps>) => {
  const defaultProps = useMemo<Partial<Button.BaseRootElementProps>>(
    () => ({
      // Calculate default props here.
    }),
    [],
  );

  // ...Internal logic here.

  const className = useMemo<Button.BaseRootElementProps["className"]>(
    () =>
      clsx(
        {
          "my-button--primary": variant === "primary",
          "my-button--secondary": variant === "secondary",
        },
        props.className,
      ),
    [props.className, variant],
  );

  const onClick = useEventCallback<
    NonNullable<Button.BaseRootElementProps["onClick"]>
  >((e) => {
    props.onClick?.(e);
    if (e.defaultPrevented) return;
    // ...Custom logic here.
  });

  return (
    <Component
      {...defaultProps}
      {...props}
      className={className}
      onClick={onClick}
    />
  );
};        

This approach allows us to achieve the following result:

const IncompatibleComponent: FC<{ onClick?: (value: Value) => void }> = () => null;
const CompatibleComponent: FC<{ onClick?: ComponentProps<"a">["onClick"] }> = () => null;

<Button component={IncompatibleComponent} onClick={(e) => e} />;
//      ^^^^^^^^^ Types of property 'onClick' are incompatible.
<Button component={CompatibleComponent} onClick={(e) => e} />;        

Conclusion

Here are the key points covered in this article:

  • Some lint rules such as no-redeclare might warn with these patterns, it's fine to disable them if we already inspect the errors with TypeScript.
  • Notice that use namespace only for types, not for runtime values.
  • Always inherit and extend all props from the base component.
  • Place defaultProps before props to allow them to be overridden.
  • Place overrideProps after props. If a prop is always overridden, omit its type; otherwise, handle the merging of props.
  • If necessary, make the rootElement overridable, especially for clickable elements, as they may need to be used with <a /> or other router components like <Link />.

When developing shared components or utilities, it’s crucial to remember that developers are the end users, so DX is essentially UX. We aim to create components that are flexible, overridable, and easy to integrate. Inferring and validating props with TypeScript are fundamental aspects of our design, as we prioritize making these components intuitive and user-friendly for developers. Furthermore, always ensure you write comprehensive tests, including runtime tests, visual tests, and type checks, to verify that the components work correctly. Do you have any effective patterns or best practices to share? We’d love to hear your insights!

要查看或添加评论,请登录

Satya Ranjan Sharma的更多文章

社区洞察

其他会员也浏览了