TypeScript + React Generic Component Typing

Dom Webber
5 min readDec 6, 2022

--

Through multiple NextJS sites’ development, I consistently reused the same container component, using three different variations for sizing, padding and margin — each nested and adding on such styling. I quickly came to realise that if I was going to keep using this same component, it was going to end up being pretty difficult to maintain and, being TypeScript-based, challenging to wrap intrinsic attributes into the react components. So, whilst initially only being a small side project, I decided to put in the work and develop my “ultimate” container using TailwindCSS and classnames/clsx for styling.

TL;DR — See the code in this gist.

Throughout the process, I deepened my knowledge about generics and how to use generics with React. Along the way, I found this StackOverflow answer and this Medium Article that really helped me understand React’s typing.

Edit 6: “For TypeScript v4.9.x and above, please also read the TypeScript Version notes at the bottom of this article. I’m not sure why it breaks but the notes detail how you can avoid a very vague ts(2322) error.” — Dom

// Container.tsx
import { ReactNode, ElementType, ComponentPropsWithoutRef } from "react";
import classNames, { Argument } from "classnames";

export interface ContainerProps<Tag extends ElementType> {
children: ReactNode;
className?: Argument;
as?: Tag;
}

type ContainerAdditionalProps<Tag extends ElementType> = Omit<
ComponentPropsWithoutRef<Tag>,
keyof ContainerProps<Tag>
>;

export default function Container<Tag extends ElementType = "div">({
children,
className,
as,
...props
}: ContainerProps<Tag> & ContainerAdditionalProps<Tag>) {
const Component = as || "div";
return (
<Component
className={classNames("md:container", "md:mx-auto", className)}
{...props}
>
{children}
</Component>
);
}

As the basis for all my container styles, this component took the longest to work out — but it was the component that taught me the most.

The key takeaway code is as follows:

type ContainerAdditionalProps<Tag extends ElementType> = Omit<
ComponentPropsWithoutRef<Tag>,
keyof ContainerProps<Tag>
>;

This type collects the additional properties that a certain intrinsic HTML element can have, without overwriting any keys from the Container’s own properties.

The “ComponentPropsWithoutRef” is an interesting type declaration, provided by React, and can be very useful when composing the properties of a component. In a simple form, it can be used such that the tag name is specified as a string, as follows:

type DivElementProps = ComponentPropsWithoutRef<"div">;

This type declaration works without passing on the “ref” property, however, a similarly named “ComponentPropsWithRef” includes the ref property when needed.

From there, I worked on the specialised style variations for the component, including the “Padded Container” that implements my standard padding size on top of the barebones container component.

// PaddedContainer.tsx
import React, { ComponentPropsWithoutRef, ElementType } from "react";
import Container, { ContainerProps } from "./Container";

export interface PaddedContainerProps<Tag extends ElementType>
extends ContainerProps<Tag> {}

type PaddedContainerAdditionalProps<Tag extends ElementType> = Omit<
ComponentPropsWithoutRef<Tag>,
keyof PaddedContainerProps<Tag>
>;

export default function PaddedContainer<Tag extends ElementType = "div">({
children,
className,
...props
}: PaddedContainerProps<Tag> & PaddedContainerAdditionalProps<Tag>) {
return (
<Container className={["px-4", className]} {...props}>
{children}
</Container>
);
}

At this point, it was easy-going for me to implement my “Standard Container”.

// StandardContainer.tsx
import React, { ComponentPropsWithoutRef, ElementType } from "react";
import PaddedContainer, { PaddedContainerProps } from "./PaddedContainer";

export interface StandardContainerProps<Tag extends ElementType>
extends PaddedContainerProps<Tag> {}

type StandardContainerAdditionalProps<Tag extends ElementType> = Omit<
ComponentPropsWithoutRef<Tag>,
keyof StandardContainerProps<Tag>
>;

export default function StandardContainer<Tag extends ElementType = "div">({
children,
className,
...props
}: StandardContainerProps<Tag> & StandardContainerAdditionalProps<Tag>) {
return (
<PaddedContainer className={["mb-8", className]} {...props}>
{children}
</PaddedContainer>
);
}

For a variant with clsx instead of classnames, you can change the base Container component (Container.tsx) and everything else can be left untouched. Changing the base Container component to the following should allow clsx to be used:

// Container.tsx
import { ReactNode, ElementType, ComponentPropsWithoutRef } from "react";
import clsx, { ClassValue } from "clsx";

export interface ContainerProps<Tag extends ElementType> {
children: ReactNode;
className?: ClassValue;
as?: Tag;
}

type ContainerAdditionalProps<Tag extends ElementType> = Omit<
ComponentPropsWithoutRef<Tag>,
keyof ContainerProps<Tag>
>;

export default function Container<Tag extends ElementType = "div">({
children,
className,
as,
...props
}: ContainerProps<Tag> & ContainerAdditionalProps<Tag>) {
const Component = as || "div";
return (
<Component
className={clsx("md:container", "md:mx-auto", className)}
{...props}
>
{children}
</Component>
);
}

Note that the main change for clsx is the import and className attribute type in the ContainerProps interface.

Additionally, if you’re working without JSX, changing the return from the Container function to the following snippet will work for you:

return createElement(Component, { className: clsx("md:container", "md:mx-auto", className), ...props }, children);

See the code in this gist.

TypeScript/React Updates

Some recent TypeScript changes (since around ~v4.8.4) cause ts(2322) errors. To counter this, the types for the “Padded Container” and “Standard Container” can be changed and simplified as such:

// PaddedContainer.tsx
// Also, export ContainerAdditionalProps from the relevant file
import React, { ElementType } from "react";
import Container, { ContainerProps, ContainerAdditionalProps } from ".";

export default function PaddedContainer<Tag extends ElementType = "div">({
className,
...props
}: ContainerProps<Tag> & Partial<ContainerAdditionalProps<Tag>>) {
// Note the Partial<> utility type above, this is the relevant change
return (
<Container className={["px-4", className]} {...props} />
);
}
// StandardContainer.tsx
// Also, export ContainerAdditionalProps from the relevant file
import React, { ElementType } from "react";
import Container, { ContainerProps, ContainerAdditionalProps } from ".";

export default function StandardContainer<Tag extends ElementType = "div">({
className,
...props
}: ContainerProps<Tag> & Partial<ContainerAdditionalProps<Tag>>) {
// Note the Partial<> utility type above, this is the relevant change
return (
<Container className={["mb-8", className]} {...props} />
);
}

Or, to stay closer to the original structure, the following snippet will avoid the error:

// PaddedContainer.tsx
import React, { ElementType, ComponentPropsWithoutRef } from "react";
import Container, { ContainerProps } from ".";

export type PaddedContainerProps<Tag extends ElementType> = ContainerProps<Tag>;

type PaddedContainerAdditionalProps<Tag extends ElementType> = Omit<
// The following line is the relevant change:
Partial<ComponentPropsWithoutRef<Tag>>,
keyof PaddedContainerProps<Tag>
>;

// The component function (following) doesn't need to change, however, the following
// is a simplified version
export default function PaddedContainer<Tag extends ElementType = "div">({
className,
...props
}: PaddedContainerProps<Tag> & PaddedContainerAdditionalProps<Tag>) {
return (
<Container className={["px-4", className]} {...props} />
);
}
// StandardContainer.tsx
import React, { ElementType, ComponentPropsWithoutRef } from "react";
import PaddedContainer, { PaddedContainerContainerProps } from "./PaddedContainer";

export type StandardContainerProps<Tag extends ElementType> = PaddedContainerProps<Tag>;

type StandardContainerAdditionalProps<Tag extends ElementType> = Omit<
// The following line is the relevant change:
Partial<ComponentPropsWithoutRef<Tag>>,
keyof StandardContainerProps<Tag>
>;

// The component function (following) doesn't need to change, however, the following
// is a simplified version
export default function StandardContainer<Tag extends ElementType = "div">({
className,
...props
}: StandardContainerProps<Tag> & StandardContainerAdditionalProps<Tag>) {
return (
<Container className={["mb-8", className]} {...props} />
);
}

Although no changes to the “Base Container” are necessary, for continuity you could also add the TypeScript Partial<> utility to the same line in the relevant file.

Version Information

These code snippets were originally written for TypeScript v4.8.4 (latest at the time). I will try to keep the snippets up to date with the current latest TypeScript version.

Current TypeScript Version: v5.0.4

Edit 1: Reduced the number of calls to classNames by passing additional classnames down to the base Container component.

Edit 2: Changed exports to export function etc…

Edit 3: Added snippets with clsx instead of classnames.

Edit 4: Added version information in preparation for upcoming “Edit 6”.

Edit 5: Added version with React.createElement() (without JSX).

Edit 6: Some changes due to TypeScript/React updates (not 100% sure but it works).

Edit 7: Added TL;DR link at the top of the article.

--

--