TL;DR: Skip to the end of the article for a working Stackblitz implementation.
If you've spent any time writing functions and classes in TypeScript, you may have stumbled upon generics which allow you to infer types being passed into a function or class without having to necessarily assign types ahead of time. What you may not have realised is that the same can be applied to JSX components.
Using generics allows JSX components to offer autocompletion and type safety for things that would otherwise have been typed as any
due to the highly generic nature of components.
In this article, we're going to use Preact as our JSX library to render components, and we'll build a table rendering component that will infer and typecheck its props based on whatever data we give it.
The Data Model
To start, we'll need to define the shape of data we'll be passing into the table component. To make things simple for the end-user, we'll start with a simple constraint — the data must be an array of objects whose properties are field identifiers:
export type TableData<T extends Record<string, unknown>> = T[];
Try this example in the TS Playground
Because the keys can be associated with any value, table cells will need to be able to render values based on their type. For some values, the end user of the table component might want to control how that field renders its values by providing their own component. To facilitate this, let's create a type that will generate props for a component based on the row type given by the user:
import type { ComponentType } from 'preact';
// a Field is any component that renders a value: <Field>{value}</Field>
export type Field<Value> = ComponentType<{ children: Value }>;
// we create an optional map of field name to component that renders that value
export type FieldRenderers<Row extends Record<string, unknown>> = {
[Prop in keyof Row]?: Field<Row[Prop]>;
};
Try this example in the TS Playground
The Components
Since we're building the table component from the ground up, let's start with the default field component:
export const DefaultField: FunctionalComponent<{ children: any }> = ({
children,
}) => {
if (typeof children == 'number') {
return <>{children.toLocaleString()}</>;
} else if (typeof children == 'string') {
return <>{children}</>;
} else if (typeof children == 'boolean') {
return <>{children ? '✅' : '❌'}</>;
} else if (children instanceof Date) {
return <>{new Intl.DateTimeFormat().format(children)}</>;
}
return <>{children}</>;
};
Try this example in the TS Playground
This component takes a couple of common types like numbers, strings, dates and booleans and renders them according to their default browser locales to nicely format them. It doesn't try to cover all possible types, but enabling the user to provide a custom renderer for a field will take care of that problem. If the value itself is a JSX element, it will also just render the value.
The next step is to create the table component that will tie all of these types together. Let's start with the component signature itself:
export const Table = <Row extends Record<string, any>>({
data,
fields = {},
columnOrder,
}: TableProps<Row>) => {
/* ... implementation detail in TS playground ... */
};
See the full implementation in the TS Playground
You'll notice that we used a generic parameter called Row
to declare a type parameter that we use to pass to the TableProps
type. This allows the component to infer the type based on the array being passed into the data
prop when the component is used.
Once the data type for data
is known from its usage, the rest of the props are adjusted accordingly; fields
now only accepts a partial object whose keys match keys in the data
array items and columnOrder
takes an array of keys. If you try to add other strings or provide other keys, you'll get a type error. Same goes for the cell renderers; it expects a class or functional component that takes children
and renders it.
Using the Table
Component
Now that we've written the Table
component, it's time to try out the neat features generics give us as a user.
First, let's define some data without explicitly defining a type for it:
const exampleData = [
{
id: 1,
name: 'Johnny Burger',
email: 'johnny@example.org',
bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce viverra malesuada dui. Vivamus ac aliquet justo, eu laoreet purus. Aliquam pulvinar, nulla ut rhoncus viverra, purus ligula tincidunt orci, a venenatis lacus libero eget urna. Vivamus dictum nunc id ligula iaculis, ac tincidunt turpis auctor. Fusce mollis viverra diam nec hendrerit. Fusce iaculis sed nisl ut cursus. Fusce dapibus mi id lacus dapibus, vel semper mauris rutrum. Maecenas mattis tincidunt leo non pulvinar. Maecenas nibh nulla, dapibus quis est sit amet, accumsan viverra sem. Fusce a fringilla lacus. Maecenas iaculis justo non finibus finibus. Quisque aliquam ultrices lacus eget porta.',
},
{
id: 2,
name: 'Henrietta Biggles',
email: 'beagle@example.org',
},
];
Try this example in the TS Playground
If you open the playground and hover over the type, you'll see that the inferred type is:
const exampleData: ({
id: number;
name: string;
email: string;
bio: string;
} | {
id: number;
name: string;
email: string;
bio?: undefined;
})[];
// ...or simplified a bit:
const exampleData: {
id: number;
name: string;
email: string;
bio?: string;
}[];
If we pass this data to the Table
component, nothing notable happens other than rendering the table:
import { render } from 'preact';
import { Table } from './table';
const exampleData = [
{
id: 1,
name: 'Johnny Burger',
email: 'johnny@example.org',
bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce viverra malesuada dui. Vivamus ac aliquet justo, eu laoreet purus. Aliquam pulvinar, nulla ut rhoncus viverra, purus ligula tincidunt orci, a venenatis lacus libero eget urna. Vivamus dictum nunc id ligula iaculis, ac tincidunt turpis auctor. Fusce mollis viverra diam nec hendrerit. Fusce iaculis sed nisl ut cursus. Fusce dapibus mi id lacus dapibus, vel semper mauris rutrum. Maecenas mattis tincidunt leo non pulvinar. Maecenas nibh nulla, dapibus quis est sit amet, accumsan viverra sem. Fusce a fringilla lacus. Maecenas iaculis justo non finibus finibus. Quisque aliquam ultrices lacus eget porta.',
},
{
id: 2,
name: 'Henrietta Biggles',
email: 'beagle@example.org',
},
];
render(
<Table data={exampleData} />,
document.body
);
Try this example in the TS Playground
And here's where all that effort starts to pay off. We want to do two things:
- We want emails to be linked using the
mailto:
protocol for easy access; and - We want to truncate the
bio
field if it is longer than 50 characters.
Let's create two components that will do this for us:
const Email: FunctionalComponent<{ children: string }> = ({ children }) => (
<a href={`mailto:${children}`}>{children}</a>
);
const Truncate: FunctionalComponent<{ children: string | undefined }> = ({
children = '',
}) => <>{children.length > 50 ? `${children!.slice(0, 50)}…` : children}</>;
Let's use these components to render the values in the two fields. If we now use the fields
prop on the Table
component, we'll get autocomplete that suggests the field names from the example data!
The same also applies for the columnOrder
which constrains the array of strings passed to only contain keys from the data passed to the component.
Working Example
To wrap things up, I've put together a working Stackblitz example where you can play around with the developer experience of using the component itself. Can you think of other examples that could benefit from type inference? Leave a comment!
Top comments (0)