Over the past few weeks, I’ve been knee-deep in creating a flexible pivot table component within Chargebee to handle various aggregates and rollups. Working with dynamic data shapes and multi-level computations can get unwieldy fast—especially when it comes to maintaining type safety. In this post, I want to share some of the approaches I’ve taken to keep the code both adaptable (when handling ever-changing data) and strictly typed (to prevent errors and ensure a smooth developer experience).
Introduction
When working with TypeScript in React, it’s easy to over-constrain your component’s types. You end up writing verbose definitions that slow you down—and still might not capture all the ways your data can change. A more balanced approach is to keep “loose” types inside your component and let TypeScript do the heavy lifting “strictly” at the usage site. In other words, let generics (and type inference) handle internal flexibility, while ensuring that whenever someone uses your component, they’ll see immediate errors if they misuse the props.
What are strict and loose types?
-
Loose types: The component’s internal code is not overly constrained. You might define a generic type parameter
T, but you don’t specify every property in detail. -
Strict types: The person (or code) calling your component must provide matching data. TypeScript checks their input against
T(or computed variants ofT) and flags any mismatches.
This pattern allows a single component to handle multiple data shapes without sacrificing type safety for the developer who uses that component.
First example: a simple pivot table
Imagine a basic PivotTable that renders a list of items in a table. You pass it data (an array of some type T) and a renderRow function to define how each item should look in a row:
import React from 'react';
interface PivotTableProps<T> {
data: T[];
renderRow: (item: T) => JSX.Element;
}
export function PivotTable<T>({ data, renderRow }: PivotTableProps<T>) {
return (
<table>
<tbody>
{data.map((item, index) => (
<tr key={index}>{renderRow(item)}</tr>
))}
</tbody>
</table>
);
}
// Sample usage
interface SimpleSale {
product: string;
price: number;
}
const simpleSales: SimpleSale[] = [
{ product: 'Apple', price: 10 },
{ product: 'Samsung', price: 12 },
];
function App() {
return (
<PivotTable
data={simpleSales}
renderRow={(item) => (
<>
<td>{item.product}</td>
<td>{item.price}</td>
</>
)}
/>
);
}
Here’s what’s happening:
-
PivotTableuses a genericTto keep its types flexible. Inside the component, we don’t over-constrain whatTmust be. - At the usage site, we pass
SimpleSaleitems, and TypeScript ensures thatrenderRowreceives an object with product and price. Any mistakes—like referencing a missing field—trigger a compile-time error.
Adding computed fields: let Typescript infer
What if you want your PivotTable to merge extra, computed fields (like totals or averages) onto each row before rendering? This is where “loose inside, strict outside” really shines. We’ll define two new props:
-
fieldsToCalculate: A function that takesTand returns an object of newly computed fields. -
visibleFields: A list of column definitions referencing both the originalTkeys and the computed keys.
interface VisibleField<T> {
key: keyof T;
label: string;
}
interface PivotTableProps<T, C> {
data: T[];
fieldsToCalculate: (row: T) => C;
visibleFields: Array<VisibleField<T & C>>;
}
export function PivotTable<T, C>({ data, fieldsToCalculate, visibleFields }: PivotTableProps<T, C>) {
// Merge original row data with computed fields
const pivotedData = data.map((row) => ({
...row,
...fieldsToCalculate(row),
}));
return (
<table>
<thead>
<tr>
{visibleFields.map((field) => (
<th key={field.key as string}>{field.label}</th>
))}
</tr>
</thead>
<tbody>
{pivotedData.map((row, rowIndex) => (
<tr key={rowIndex}>
{visibleFields.map((field) => (
<td key={field.key as string}>
{String(row[field.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Key point: We use two generic parameters, T (for the base data type) and C (for computed fields). Inside PivotTable, we do not specify the shape of C; TypeScript will infer it at the usage site.
Usage
Below, Sale describes the raw data. We call fieldsToCalculate, which returns { total, avg }—and we let TypeScript figure out that means C = { total: number; avg: number }.
interface Sale {
date: string;
product: string;
price: number;
quantity: number;
}
const sales: Sale[] = [
{ date: '2024Q4', product: 'Apple', price: 25, quantity: 5 },
{ date: '2025Q1', product: 'Apple', price: 30, quantity: 4 },
];
function App() {
return (
<PivotTable
data={sales}
fieldsToCalculate={(row) => {
const total = row.price * row.quantity;
return {
total,
avg: total / row.quantity,
};
}}
visibleFields={[
{ key: 'date', label: 'Date' },
{ key: 'product', label: 'Product' },
{ key: 'price', label: 'Price' },
{ key: 'quantity', label: 'Quantity' },
{ key: 'total', label: 'Total' },
{ key: 'avg', label: 'Avg' },
]}
/>
);
}
- If you try referencing a field that doesn’t exist on Sale or the computed object (like
row.unknown), TypeScript will issue an error at compile time. - Since
visibleFieldsreferencesT & C, you can use both original keys(date, product, price, quantity)and computed keys(total, avg)in your columns.
Expanding with dynamic columns
Often, you won’t hard-code your fieldsToCalculate and visibleFields; you’ll build them dynamically based on user input or runtime logic. Let’s define them as variables:
// 1. Base type
interface Sale {
date: string;
product: string;
price: number;
quantity: number;
}
// 2. Computed fields
function computeFields(row: Sale) {
const total = row.price * row.quantity;
const avg = total / row.quantity;
return { total, avg };
}
// 3. Full shape after merging base + computed
type Computed = ReturnType<typeof computeFields>;
type Combined = Sale & Computed;
const dynamicFields: Array<VisibleField<Combined>> = [
{ key: 'date', label: 'Date' },
{ key: 'product', label: 'Product' },
{ key: 'price', label: 'Price' },
{ key: 'quantity', label: 'Quantity' },
{ key: 'total', label: 'Total' },
{ key: 'avg', label: 'Avg' },
];
const sales: Sale[] = [
{ date: '2024Q4', product: 'Apple', price: 25, quantity: 5 },
{ date: '2025Q1', product: 'Apple', price: 30, quantity: 4 },
{ date: '2024Q4', product: 'Samsung', price: 15, quantity: 7 },
{ date: '2025Q1', product: 'Samsung', price: 20, quantity: 5 },
{ date: '2024Q4', product: 'Xiaomi', price: 12, quantity: 6 },
{ date: '2025Q1', product: 'Xiaomi', price: 14, quantity: 10 },
];
function App() {
return (
<PivotTable<Sale, Computed>
data={sales}
fieldsToCalculate={computeFields}
visibleFields={dynamicFields}
/>
);
}
In this final form:
- We combine the base
Saleand computed{ total, avg }into a Combined type. -
dynamicFieldsis strongly typed toArray<VisibleField<Combined>>, so every key must exist onCombined. - The pivot table remains flexible inside (
<PivotTable<T, C>>), and we keep “strict” definitions where it’s used.
Conclusion
By structuring your types to be “loose” inside a component and “strict” at the usage site, you get the best of both worlds:
- Flexibility: Internally, you can merge data, compute fields, and handle many shapes without writing massive type definitions.
- Safety: Externally, the consumer of your component benefits from precise TypeScript checks. If someone accidentally references a non-existent field, they’ll catch it well before runtime.
This approach is especially powerful for complex components like pivot tables, data grids, or advanced dashboards—anything that transforms or extends data. Ultimately, TypeScript’s generics and inference features make it possible to keep your code lean without giving up the robust type safety that makes TypeScript so valuable in the first place.
Top comments (0)