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
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 usedany
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.