DEV Community

Cover image for Loose inside, strict outside: a TypeScript pattern for complex React components
Alex Khomenko
Alex Khomenko

Posted on

Loose inside, strict outside: a TypeScript pattern for complex React components

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

Here’s what’s happening:

  • PivotTable uses a generic T to keep its types flexible. Inside the component, we don’t over-constrain what T must be.
  • At the usage site, we pass SimpleSale items, and TypeScript ensures that renderRow receives 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:

  1. fieldsToCalculate: A function that takes T and returns an object of newly computed fields.
  2. visibleFields: A list of column definitions referencing both the original T keys 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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' },
      ]}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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 visibleFields references T & 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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

In this final form:

  • We combine the base Sale and computed { total, avg } into a Combined type.
  • dynamicFields is strongly typed to Array<VisibleField<Combined>>, so every key must exist on Combined.
  • 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:

  1. Flexibility: Internally, you can merge data, compute fields, and handle many shapes without writing massive type definitions.
  2. 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)