Quick Start
This guide builds one invoice report and exports it twice: once from an array, once streamed in batches. The schema doesn't change between the two.
1. Describe the rows
type Invoice = {
invoiceId: string;
customer: string;
qty: number;
unitPrice: number;
taxRate: number;
status: "paid" | "pending" | "overdue";
};
2. Build the schema
Each .column() adds a column. accessor reads a field, formula writes an Excel formula, summary adds a row below the data, and type: "badge" renders a status as a colored pill.
import { createExcelSchema } from "typed-xlsx";
const money = { numFmt: "$#,##0.00" };
export const invoiceSchema = createExcelSchema<Invoice>()
.column("invoiceId", { header: "Invoice #", accessor: "invoiceId" })
.column("customer", { header: "Customer", accessor: "customer" })
.column("qty", { header: "Qty", accessor: "qty" })
.column("unitPrice", { header: "Unit price", accessor: "unitPrice", style: money })
.column("subtotal", {
header: "Subtotal",
formula: ({ refs, fx }) =>
fx.round(refs.column("qty").mul(refs.column("unitPrice")), 2),
style: money,
summary: (s) => [s.formula("sum")],
})
.column("taxRate", { header: "Tax", accessor: "taxRate", style: { numFmt: "0%" } })
.column("total", {
header: "Total",
formula: ({ refs, fx }) =>
fx.round(
refs.column("subtotal").mul(fx.literal(1).add(refs.column("taxRate"))),
2,
),
style: money,
summary: (s) => [s.formula("sum")],
})
.column("status", {
header: "Status",
type: "badge",
accessor: "status",
variants: {
paid: {
style: {
fill: { color: { rgb: "DCFCE7" } },
font: { color: { rgb: "166534" } },
},
},
pending: {
style: {
fill: { color: { rgb: "FEF3C7" } },
font: { color: { rgb: "92400E" } },
},
},
overdue: {
style: {
fill: { color: { rgb: "FEE2E2" } },
font: { color: { rgb: "991B1B" } },
},
},
},
})
.build();
Try a typo: refs.column("subtota") or accessor: "unitprice" is a type error, because both are checked against the schema and the Invoice type.
3. Export it from an array
import { createWorkbook } from "typed-xlsx";
const rows: Invoice[] = [
{
invoiceId: "INV-001",
customer: "Acme Corp",
qty: 10,
unitPrice: 299,
taxRate: 0.2,
status: "paid",
},
{
invoiceId: "INV-002",
customer: "Globex Inc",
qty: 5,
unitPrice: 850,
taxRate: 0.2,
status: "pending",
},
{
invoiceId: "INV-003",
customer: "Initech",
qty: 2,
unitPrice: 1200,
taxRate: 0.2,
status: "overdue",
},
];
const workbook = createWorkbook();
workbook.sheet("Invoices", { freezePane: { rows: 2 } }).table("invoices", {
title: "Invoices, Q1 2025",
rows,
schema: invoiceSchema,
});
await workbook.writeToFile("./invoices.xlsx");
// or: workbook.toBuffer() for an HTTP response or an S3 upload
This is the sheet it writes:
- 1
E3:E5subtotal is a live formula, recalculated when someone edits Qty or Unit price - 2
E6:G6summary rows add SUM formulas under the data
Rows 1 and 2 (title and headers) are frozen, so they stay visible while scrolling.
4. Stream it in batches
When the rows come from a database cursor or a paginated API, use createWorkbookStream() with the same schema and commit pages as they arrive:
import { createWorkbookStream } from "typed-xlsx";
const workbook = createWorkbookStream();
const table = await workbook
.sheet("Invoices", { freezePane: { rows: 2 } })
.table("invoices", { title: "Invoices, Q1 2025", schema: invoiceSchema });
for await (const page of fetchInvoicePages()) {
await table.commit({ rows: page });
}
await workbook.writeToFile("./invoices.xlsx");
The file is the same; the summary row is computed across every committed batch. Streaming supports every schema feature, including formulas, summary rows, sub-row expansion and excel-table mode.
Next steps
- Defining Columns: accessors, transforms, widths
- Formula Columns: the formula DSL
- Table Mode: native Excel tables with filters
- Large Exports: when and how to stream