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.
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();
Core features
Explore the full surface area of the package
Typed accessors and reusable schemas
Define columns once with typed paths, callback accessors, transforms, defaults, and stable ids.
Context-driven groups
Expand one schema into many columns from runtime context without giving up a typed API.
Styling, formatting, and smart defaults
Apply number formats, header styles, wrap behavior, and fallback values with a library-owned style model.
Transforms, summaries, and sub-rows
Derive values, expand arrays into physical sub-rows, and accumulate one or many summary rows.
Grid layouts, freeze panes, and RTL
Compose several tables on one sheet, control placement with tablesPerRow, and tune the view.
Simple workbook composition in memory
Use the buffered builder when your dataset fits in memory and you want the smallest API surface.
Commit-based exports for large datasets
Commit row batches incrementally, keep memory bounded, and pipe the final workbook to files or streams.
Benchmark-backed guidance for big exports
Tune temp storage and string modes, and choose the right builder for 100k to 500k+ row workloads.
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.
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();
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.