DEV Community

Cover image for Supercharging Autocomplete and Typechecking for JSX Components with Generics!
Klemen Slavič
Klemen Slavič

Posted on

Supercharging Autocomplete and Typechecking for JSX Components with Generics!

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[];
Enter fullscreen mode Exit fullscreen mode

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]>;
};
Enter fullscreen mode Exit fullscreen mode

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}</>;
};
Enter fullscreen mode Exit fullscreen mode

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 ... */
};
Enter fullscreen mode Exit fullscreen mode

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',
  },
];
Enter fullscreen mode Exit fullscreen mode

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;
}[];
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

Try this example in the TS Playground

And here's where all that effort starts to pay off. We want to do two things:

  1. We want emails to be linked using the mailto: protocol for easy access; and
  2. 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}</>;
Enter fullscreen mode Exit fullscreen mode

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!

Image description
Try this in the TS Playground

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)