Alain Perkaz

Typed Custom HTML attributes in React

TypeScript (TS) marks all custom attributes as valid, as per the official docs:

Note: If an attribute name is not a valid JS identifier (like a data-* attribute), it is not considered to be an error if it is not found in the element attributes type.

This eases working with HTML elements, as it provides correct typing by default for features such as data attributes:

<article id="gas-cars" data-columns="1" data-parent="cars"></article>

TS types:

(property) data-columns: string
(property) data-parent: string

Complications in React

While this feature helps with HTML elements, it leads to properties not being properly type-checked when dealing with custom React components:

const Greet = ({ name }: { name: string }) => <h1>Hi {name}!</h1>;

const App = () => (
	<>
		{/* ✅ error: property `name` is missing */}
		<Greet />
		{/* ✅ no error */}
		<Greet name="Bob" />
		{/* ✅ error: property 'fakeProp' does not exist on type */}
		<Greet name="Bob" fakeProp={1} />

		{/* ❌ no errors: despite 'fake-prop' / 'data-id' not existing! */}
		<Greet name="Bob" fake-prop={1} data-id={2} />
	</>
);

This TypeScript behavior can lead to confusion, compounded when dealing with reusable components such as a component-libraries. Consumers can add properties that are not supported to a given component, without any effect (data-testid or data-cy for example).

In the next section we will look into two potential approaches to address this shortcoming.

Alternatives: compile-time checks

On the one-side, its possible to restrict the usage of certain properties with TS compile-time checks.

This approach does not require JS code, but it wont flag all the custom properties.

Its useful to notify component users when they are using a known custom attribute that wont have the desired effect:

type Override<
	Type,
	NewType extends { [key in keyof Type]?: NewType[key] }
> = Omit<Type, keyof NewType> & NewType;

/**
 * Constrains the main custom attributes,
 * so that typescript does the validation work for us.
 */
type RestrictCustomAttributes = {
	/** no included by default */
	"data-cy"?: never;
	/** no included by default */
	"data-testid"?: never;
};

export const CompileTime = ({
	name,
	"data-cy": dataCy,
}: Override<
	RestrictCustomAttributes,
	{
		name: string;
		"data-cy"?: string;
	}
>) => <h1 data-cy={dataCy}>{name}</h1>;

Alternatives: runtime-time checks

On the other side, exhaustive property checks are possible through schema checking at runtime (zod or alternatives can be used).

This means that every component render will have an overhead due to the parsing of the props against the schema.

In return, if a component is fed a property that does not explicitly support, direct feedback will be provided (through a runtime error).

import { z } from "zod";

// run-time checks through schema
const propSchema = z
	.object({
		name: z.string(),
		"data-cy": z.string().optional(),
	})
	.strict();

export const RunTime = (props: z.infer<typeof propSchema>) => {
	const { name, "data-cy": dataCy } = propSchema.parse(props);
	return <h1 data-cy={dataCy}>{name}</h1>;
};

Happy codding! 🎉