Typed-xlsx
Type-safe Excel reporting

Feature-rich
type-safe
Excel reporting

@chronicstone/typed-xlsx helps you craft complex Excel reports with ease in TypeScript, with typed schemas, reusable formatting, multi-sheet workbooks, summaries, and advanced table layouts.

report.ts
import { 
createExcelSchema
,
createWorkbook
} from "@chronicstone/typed-xlsx";
type
User
= {
firstName
: string;
lastName
: string;
countries
: string[] };
const
schema
=
createExcelSchema
<
User
>()
.
column
("firstName", {
header
: "First name",
accessor
: "firstName" })
.
column
("lastName", {
header
: "Last name",
accessor
: "lastName" })
.
column
("countries", {
header
: "Countries",
accessor
: (
row
) =>
row
.
countries
.
join
(", "),
}) .
build
();
const
workbook
=
createWorkbook
();
workbook
.
sheet
("Users").
table
({
rows
: [{
firstName
: "Ada",
lastName
: "Lovelace",
countries
: ["UK"] }],
schema
,
}); const
bytes
=
workbook
.
toUint8Array
();
Schema

Typed accessors and reusable schemas

Define columns once with typed paths, callback accessors, transforms, defaults, and stable ids.

Dynamic columns

Context-driven groups

Expand one schema into many columns from runtime context without giving up a typed API.

Presentation

Styling, formatting, and smart defaults

Apply number formats, header styles, wrap behavior, and fallback values with a library-owned style model.

Data shaping

Transforms, summaries, and sub-rows

Derive values, expand arrays into physical sub-rows, and accumulate one or many summary rows.

Layout

Grid layouts, freeze panes, and RTL

Compose several tables on one sheet, control placement with tablesPerRow, and tune the view.

Buffered builder

Simple workbook composition in memory

Use the buffered builder when your dataset fits in memory and you want the smallest API surface.

Streaming

Commit-based exports for large datasets

Commit row batches incrementally, keep memory bounded, and pipe the final workbook to files or streams.

Scale

Benchmark-backed guidance for big exports

Tune temp storage and string modes, and choose the right builder for 100k to 500k+ row workloads.

Upgrade

Migration notes for the v1 API

See how key became accessor, how summaries changed, and where to find the new builders.

Live example

Explore a real financial report source side by side with the generated workbook

The original landing shipped with a live example. This version keeps that idea, but inside the Docus experience, with the same schema, data, and file builder source available in a resizable split view.

Live example

Financial report source + workbook preview

Browse the exact schema, data, and file builder source while previewing the generated workbook.

export const faker: any;
// ---cut---
// @filename: data.ts
import { faker } from "@faker-js/faker";

export interface FinancialReport {
  month: string;
  departments: DepartmentData[];
  totalRevenue: number;
  totalExpenses: number;
  totalProfit: number;
  averageProfitMargin: number;
  operatingCashFlow: number;
  investingCashFlow: number;
  financingCashFlow: number;
  grossMargin: number;
  EBIT: number;
  debtToEquityRatio: number;
  ROI: number;
  YoYGrowth: number;
}

export interface DepartmentData {
  name: string;
  revenue: number;
  targetRevenue: number;
  expenses: number;
  profit: number;
  profitMargin: number;
  revenueAchievement: number;
  growth: number;
  COGS: number; // New field for Cost of Goods Sold
}

export function generateFinancialReportData(
  months: number,
  departmentsPerMonth: number,
): FinancialReport[] {
  const departments = ["Sales", "Marketing", "R&D", "Customer Support", "Human Resources"];

  return Array.from({ length: months }, () => {
    const month = faker.date
      .between({ from: "2023-01-01", to: "2023-12-31" })
      .toISOString()
      .slice(0, 7);
    let totalRevenue = 0;
    let totalExpenses = 0;
    let totalProfit = 0;
    let totalProfitMargin = 0;
    let totalCOGS = 0;

    const departmentData = Array.from({ length: departmentsPerMonth }, () => {
      const name = faker.helpers.arrayElement(departments);
      const targetRevenue = faker.number.int({ min: 20000, max: 120000 });
      const revenue = faker.number.int({ min: 10000, max: 100000 });
      const expenses = faker.number.int({ min: 5000, max: 50000 });
      const profit = revenue - expenses;
      const COGS = faker.number.int({ min: 2000, max: 40000 });
      const profitMargin = Number.parseFloat(((profit / revenue) * 100).toFixed(2));

      totalRevenue += revenue;
      totalExpenses += expenses;
      totalProfit += profit;
      totalProfitMargin += profitMargin;
      totalCOGS += COGS;

      return {
        name,
        revenue,
        targetRevenue,
        expenses,
        profit,
        profitMargin,
        revenueAchievement: Number.parseFloat(((revenue / targetRevenue) * 100).toFixed(2)),
        growth: revenue - targetRevenue,
        COGS,
      };
    });

    const grossMargin =
      totalRevenue > 0
        ? Number.parseFloat((((totalRevenue - totalCOGS) / totalRevenue) * 100).toFixed(2))
        : 0;
    const averageProfitMargin =
      departmentsPerMonth > 0 ? totalProfitMargin / departmentsPerMonth : 0;

    return {
      month,
      departments: departmentData,
      totalRevenue,
      totalExpenses,
      totalProfit,
      averageProfitMargin,
      operatingCashFlow: faker.number.int({ min: 1000, max: 30000 }),
      investingCashFlow: faker.number.int({ min: -20000, max: 10000 }),
      financingCashFlow: faker.number.int({ min: -10000, max: 5000 }),
      grossMargin,
      EBIT: totalProfit - faker.number.int({ min: 1000, max: 5000 }),
      debtToEquityRatio: Number.parseFloat(
        faker.finance.amount({ min: 2000, max: 40000, dec: 2, symbol: "%", autoFormat: true }),
      ),
      ROI: Number.parseFloat(
        faker.finance.amount({ min: 2000, max: 40000, dec: 2, symbol: "%", autoFormat: true }),
      ),
      YoYGrowth: Number.parseFloat(
        faker.finance.amount({ min: -5000, max: 5000, dec: 2, symbol: "%", autoFormat: true }),
      ),
    };
  });
}
// ---cut---
// @filename: schema.ts
import { createExcelSchema, type CellStyle } from "@chronicstone/typed-xlsx";
import type { FinancialReport } from "./data";

function currencyStyle(): CellStyle {
  return {
    numFmt: "$#,##0.00",
  };
}

function percentPointStyle(): CellStyle {
  return {
    numFmt: '0.00"%"',
  };
}

function profitColor(rgb: string): CellStyle {
  return {
    font: {
      color: { rgb },
    },
  };
}

export const financialReportSchema = createExcelSchema<FinancialReport>()
  .column("month", {
    header: "Month",
    accessor: "month",
    format: "MMM YYYY",
  })
  .column("departmentName", {
    header: "Department",
    accessor: (row) => row.departments.map((department) => department.name),
  })
  .column("revenue", {
    header: "Revenue",
    accessor: (row) => row.departments.map((department) => department.revenue),
    style: currencyStyle(),
  })
  .column("expenses", {
    header: "Expenses",
    accessor: (row) => row.departments.map((department) => department.expenses),
    style: currencyStyle(),
  })
  .column("profit", {
    header: "Profit",
    accessor: (row) => row.departments.map((department) => department.profit),
    style: (row, _rowIndex, subRowIndex) =>
      ({
        ...currencyStyle(),
        ...profitColor(row.departments[subRowIndex]?.profit >= 0 ? "007500" : "FF0000"),
      }) satisfies CellStyle,
  })
  .column("profitMargin", {
    header: "Profit Margin",
    accessor: (row) => row.departments.map((department) => `${department.profitMargin}%`),
    style: (row, _rowIndex, subRowIndex) =>
      profitColor(row.departments[subRowIndex]?.profitMargin >= 0 ? "007500" : "FF0000"),
  })
  .column("totalRevenue", {
    header: "Total Revenue",
    accessor: "totalRevenue",
    style: currencyStyle(),
    summary: (summary) => [
      summary.cell({
        init: () => 0,
        step: (acc: number, row) => acc + row.totalRevenue,
        finalize: (acc: number) => acc,
        style: currencyStyle(),
      }),
    ],
  })
  .column("totalExpenses", {
    header: "Total Expenses",
    accessor: "totalExpenses",
    style: currencyStyle(),
    summary: (summary) => [
      summary.cell({
        init: () => 0,
        step: (acc: number, row) => acc + row.totalExpenses,
        finalize: (acc: number) => acc,
        style: currencyStyle(),
      }),
    ],
  })
  .column("totalProfit", {
    header: "Total Profit",
    accessor: "totalProfit",
    style: (row) =>
      ({
        ...currencyStyle(),
        ...profitColor(row.totalProfit >= 0 ? "007500" : "FF0000"),
      }) satisfies CellStyle,
    summary: (summary) => [
      summary.cell({
        init: () => 0,
        step: (acc: number, row) => acc + row.totalProfit,
        finalize: (acc: number) => acc,
        style: (value) =>
          ({
            ...currencyStyle(),
            ...profitColor((Number(value) || 0) >= 0 ? "007500" : "FF0000"),
          }) satisfies CellStyle,
      }),
    ],
  })
  .column("averageProfitMargin", {
    header: "Average Profit Margin",
    accessor: "averageProfitMargin",
    style: (row) => ({
      ...percentPointStyle(),
      ...profitColor(row.averageProfitMargin >= 0 ? "007500" : "FF0000"),
    }),
    summary: (summary) => [
      summary.cell({
        init: () => ({ total: 0, count: 0 }),
        step: (acc: { total: number; count: number }, row) => ({
          total: acc.total + row.averageProfitMargin,
          count: acc.count + 1,
        }),
        finalize: (acc: { total: number; count: number }) =>
          acc.count > 0 ? acc.total / acc.count : 0,
        style: (value) => ({
          ...percentPointStyle(),
          ...profitColor((Number(value) || 0) >= 0 ? "007500" : "FF0000"),
        }),
      }),
    ],
  })
  .build();
Workbook previewExcel

Ready to build your first
typed Excel report?

Start with the benefits, install the package, then move through schema builder and file builder step by step.

Copyright © 2026 Cyprien Thao. Released under the MIT License.