typed-xlsx
Core Concepts

Execution Model

createWorkbook() builds from rows in memory; createWorkbookStream() commits batches to a temporary spool. What each one holds in memory and when to use it.

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.

  1. 01Rowsrows: T[]the whole array, in memory
  2. 02Plan.table(id, { rows })expansion, merges, summaries
  3. 03Serializesheet XMLbuilt in memory
  4. 04OutputtoBuffer() · 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.

  1. 01Sourcepages()a cursor or paginated API, one page at a time
  2. 02Commitcommit({ rows })serialized right away
  3. 03SpooltempStorage: "file"XML appended to a temp file
  4. 04OutputwriteToFile() · 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 inputrows on .table()table.commit({ rows }), any number of times
.table() returnsthe sheet, chainable and synchronousa Promise of the table handle
OutputtoBuffer(), toUint8Array(), writeToFile()writeToFile(), pipeTo(), pipeToNode(), toReadableStream(), toNodeReadable()
Summary rowscomputed from all rowsaccumulated across commits
Featuresevery schema featureevery schema feature

The schema doesn't change between the two, so you can start buffered and switch to streaming without touching it.

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