Execution Model
typed-xlsx has two workbook builders. They accept the same schemas and support the same features. They differ in how rows reach the file.
createWorkbook(): all rows at once
You pass rows to .table(), then call an output method. The builder plans every row, writes the sheet XML and zips the workbook in one pass.
- 01Rows
rows: T[]the whole array, in memory - 02Plan
.table(id, { rows })expansion, merges, summaries - 03Serialize
sheet XMLbuilt in memory - 04Output
toBuffer() · writeToFile()zipped in one go
import { createExcelSchema, createWorkbook } from "typed-xlsx";
type Invoice = { invoiceId: string; customer: string; amount: number };
const schema = createExcelSchema<Invoice>()
.column("invoiceId", { header: "Invoice #", accessor: "invoiceId" })
.column("customer", { header: "Customer", accessor: "customer" })
.column("amount", {
header: "Amount",
accessor: "amount",
style: { numFmt: "$#,##0.00" },
})
.build();
const workbook = createWorkbook();
workbook.sheet("Invoices").table("invoices", {
schema,
rows: [{ invoiceId: "INV-001", customer: "Acme Corp", amount: 4200 }],
});
const bytes = workbook.toUint8Array();
Use it when the rows are already in memory and the report is a normal size. It has the simplest API: sheets and tables chain synchronously, and there is no commit loop.
createWorkbookStream(): rows in batches
You open a table, then commit() pages of rows as they arrive. Each batch is serialized and appended to a temporary spool, on disk by default. Finalizing streams the spool into the ZIP.
- 01Source
pages()a cursor or paginated API, one page at a time - 02Commit
commit({ rows })serialized right away - 03Spool
tempStorage: "file"XML appended to a temp file - 04Output
writeToFile() · pipeTo()spool streamed into the ZIP
import { createExcelSchema, createWorkbookStream } from "typed-xlsx";
type Order = { orderId: string; sku: string; qty: number };
declare function pages(): AsyncIterable<Order[]>;
const schema = createExcelSchema<Order>()
.column("orderId", { header: "Order ID", accessor: "orderId" })
.column("sku", { header: "SKU", accessor: "sku" })
.column("qty", { header: "Qty", accessor: "qty", summary: (s) => [s.formula("sum")] })
.build();
const workbook = createWorkbookStream();
const table = await workbook.sheet("Orders").table("orders", { schema });
for await (const page of pages()) {
await table.commit({ rows: page });
}
await workbook.writeToFile("./orders.xlsx");
Use it when rows come from a database cursor or a paginated API, or when the export is large enough that holding every row would strain your runtime.
What stays in memory
With the buffered builder, memory grows with the whole report: the rows, the planned cells and the sheet XML.
With the streaming builder, a committed batch is released once it is written to the spool. What remains is small and per-workbook (the style registry, summary accumulators), plus, depending on the schema:
- the shared string table, unless you use inline strings
- merge ranges from sub-row expansion
- hyperlinks and embedded images
- row ranges for summary formulas that read logical rows
So memory grows slowly with the row count instead of staying flat. Measured on the current engine, an 11-column export with inline strings peaks at 274 MiB for 200k rows and 279 MiB for 500k rows; see Benchmarks and Large Exports.
API differences
createWorkbook() | createWorkbookStream() | |
|---|---|---|
| Row input | rows on .table() | table.commit({ rows }), any number of times |
.table() returns | the sheet, chainable and synchronous | a Promise of the table handle |
| Output | toBuffer(), toUint8Array(), writeToFile() | writeToFile(), pipeTo(), pipeToNode(), toReadableStream(), toNodeReadable() |
| Summary rows | computed from all rows | accumulated across commits |
| Features | every schema feature | every schema feature |
The schema doesn't change between the two, so you can start buffered and switch to streaming without touching it.