Benefits of using TS Generics in your Design System

Design Systems & TypeScript are a few of the common denominators in organizations for Product & Tech. Use Generics to have a better dev experience

Benefits of using TS Generics in your Design System

The design system is a crucial building block in any organizations' holistic impression.

And using TypeScript is becoming one of the standard practices.

Today I wanted to talk about one of the use cases I came recently where the design system component needs to work on the data passed to it.

But in general, the component does not know the type of the data being passed to it. We can only error check to make sure we are not breaking the application by passing the wrong type of data to those components.

Let's assume a simple case of Button which is supposed to pass the data to the onClick callback:

export const Button = (
  { data, children }: { data: Record<string, any>, children: ReactNode }
) => (
  <button onClick={() => onClick(data)}>
    {children}
  </button>
)

And the above button is used in the page as:

export const Page = () => {
  const [count, setCount] = useState(0);
  return (
    <Button
      data={count}
      onClick={(d: number): number => setState(d + 1)}
    >Add 1</Button>
  )
}

Here the onClick inside the page knows about the type of data it is operating on but the onClick inside the Button component doesn't have any idea, except the generic Record<string, any>

To make the case, I have used any in above example; but in real world app, any MUST be replaced by strong intersection of most used types in DS to say the least

Now let's consider an alternate solution where we create the Button component in the design system with typescript generics

But before jumping to the use of TS Generics, what are generics?

Generics are a simple way to make template style types which can be can be customized and applied when they are used

For example, in the following TS in the playground

We have a Vehicle which can be upgraded but with the help of Generics, we have template type T in the Vehicle definition.

interface Car {
  name: string;
  model: string;
}

interface Vehicle<T> {
  wheels: number;
  getName(data: T): string;
  upgrade(data: T): T
}

const ferrari: Car = { name: 'Ferrari', model: 'Roma' }
const vehicle: Vehicle<Car> = { 
  wheels: 4,
  getName(car) { return car.name },
  upgrade(car) { return { ...car, model: car.model + 'X' } }
}

console.log(ferrari, vehicle)
// [LOG]: { "name": "Ferrari", "model": "Roma" }, { "wheels": 4 }  

console.log(vehicle.getName(ferrari))
// [LOG]: "Ferrari"  

console.log(vehicle.upgrade(ferrari))
// [LOG]: { "name": "Ferrari", "model": "RomaX" }

When we initialize a variable for the Vehicle interface, we pass the Template to the interface to be used in the rest of the interface declaration.

Let's see how it works in React Components. Here we will reimplement the above-mentioned button component with the Generics.

// ? Use Generics for Design System components
export const Button = <T extends unknown>(
  { data, children }: { data: T, children: ReactNode }
) => (
  <button onClick={() => onClick(data)}>
    {children}
  </button>
)

Now here with Generics, we have replaced the need for Record and any with the simple template which can customize the component's type bindings with correct data.


Well above was a very simple example, let me show you a complicated component in the Design System which will definitely need the Generics.

Such component is, though anything that iterates on data collection can follow the same principle.

Here is a simple Table I needed to come up with very basic tabular interface needs:

import { ReactNode } from 'react';
import styled, { css } from 'styled-components';

export interface TableCell<T extends unknown> {
  label: ReactNode;
  key: keyof T;
  render?: (row: T) => ReactNode;
  align?: 'left' | 'right';
  sortable?: boolean;
  maxWidth?: string;
}

interface TableProps<T> {
  rows: Array<T>;
  cells: TableCell<T>[];
  onRowClick?: (row: T) => void;
  onHeaderClick?: (key: keyof T) => void;
  noResultsMessage?: ReactNode;
}

interface AlignmentProps {
  align: 'left' | 'right';
}

export const Label = styled.label`
  display: block;
  font-size: 1.2rem;
  color: #aaa;
`;

const Row = styled.tr`
  cursor: pointer;
  font-size: 1.1rem;

  &:nth-child(even) { background-color: #fafafa; }
  &:hover { background-color: #eee; }
`;

const defaultCellCss = css<AlignmentProps>`
  padding: 0.75rem 0.5rem;
  text-align: ${(props) => props.align ?? 'left'};
`;

const Cell = styled.td<AlignmentProps & { maxWidth?: string }>`
  ${defaultCellCss}

  ${Label} { display: none; }
`;

const StyledTable = styled.table`
  border-collapse: collapse;
  width: 100%;

  @media (max-width: 768px) {
    &, tbody, ${Row} { display: grid; }
    thead { display: none; }
    
    ${Row} {
      grid-template-columns: 1fr 1fr;
      grid-gap: 0.5rem;
      padding: 0.5rem;
    }

    ${Cell} {
      text-align: left;
      padding: 0;

      ${Label} {
        display: block;
        font-size: 0.75em;
        line-height: 1.2;
      }
    }
  }
`;

const HeaderCell = styled.th<AlignmentProps & { clickable: boolean }>`
  ${defaultCellCss}
  background-color: #eee;
  ${(props) => props.clickable && `
    cursor: pointer;
    text-decoration: underline;
    text-decoration-style: dotted;
    &:hover { background-color: #ddd; }
  `};
`;

const NOOP = () => {};

export const Table = <T extends unknown>({
  rows,
  cells,
  onRowClick = NOOP,
  onHeaderClick,
  noResultsMessage = null,
}: TableProps<T>): JSX.Element => (
  <StyledTable>
    {Boolean(rows.length && noResultsMessage) && (
      <thead>
        <tr>
          {cells.map(({ label, align = 'left', key, sortable }) => (
            <HeaderCell
              key={key as string}
              align={align}
              clickable={Boolean(sortable)}
              onClick={() =>
                (sortable && onHeaderClick ? onHeaderClick : NOOP)(key)
              }
            >
              {label}
              {sortable && <small>⇵</small>}
            </HeaderCell>
          ))}
        </tr>
      </thead>
    )}
    <tbody>
      {rows.map((row: T, index: number) => (
        <Row
          key={index}
          onClick={() => onRowClick(row)}
          title="Click to open payment details"
        >
          {cells.map(
            ({
              label,
              key,
              render,
              align = 'left',
              maxWidth,
            }: TableCell<T>): ReactNode => {
              const cellKey = key as string;
              if (typeof render === 'function') {
                return (
                  <Cell align={align} key={cellKey} maxWidth={maxWidth}>
                    <Label>{label}</Label>
                    {render(row)}
                  </Cell>
                );
              }
              if (key) {
                return (
                  <Cell align={align} key={cellKey} maxWidth={maxWidth}>
                    <Label>{label}</Label>
                    <>{row[key]}</>
                  </Cell>
                );
              }
              return <Cell align={align} key={cellKey} />;
            }
          )}
        </Row>
      ))}
      {rows.length === 0 && noResultsMessage && (
        <tr>
          <td colSpan={cells.length}>{noResultsMessage}</td>
        </tr>
      )}
    </tbody>
  </StyledTable>
);

Here we needed to pass the data from each iteration passed on to the cell renderers and click handlers of rows and columns.

And following use of the above design system component:

type MockRow = {
  id: string;
  created: number;
  customer_name: string;
};
const mockConfig: TableCell<MockRow>[] = [
  {
    label: 'Customer Name',
    key: 'customer_name',
  },
  {
    label: 'Created On',
    key: 'created',
    render: (row: MockRow) => <>{new Date(row.created).toLocaleDateString()}</>,
  },
];

const mockRow = {
  id: 'i2NJhL',
  created: 1649023200000,
  customer_name: 'Émile Zola',
};

export const Page = () => (
  <Table
    onRowClick={console.log}
    cells={mockConfig}
    rows={[mockRow]}
  />
);

Conclusion

With this, I would conclude the need and use of TS generics in the Design System components.

Are you using generics in your design system component?

Let us know what are the common use cases in your codebase for Generics through comments ? or on Twitter at @heypankaj_ and/or @time2hack

If you find this article helpful, please share it with others ?

Subscribe to the blog to receive new posts right to your inbox.