Excel Reporting
Re-Engineered.
Schema-driven XLSX generation for TypeScript. If the export definition is wrong, the compiler tells you — not the spreadsheet.
npm install @chronicstone/typed-xlsx MIT license Zero dependencies schema + formula + export
Invoice report — full example
const const schema: SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">schema = createExcelSchema<Invoice>(): SchemaBuilder<Invoice, never, never, {}> (+2 overloads)createExcelSchema<type Invoice = {
id: string;
customer: string;
qty: number;
unitPrice: number;
taxRate: number;
status: "paid" | "pending" | "overdue";
}
Invoice>()
.SchemaBuilder<Invoice, never, never, {}>.column<"id", "id">(id: "id", definition: AccessorColumnInput<Invoice, "id", "id", never, never>): SchemaBuilder<Invoice, "id", never, {}> (+2 overloads)column("id", {
header?: LazyText | undefinedheader: "Invoice #",
accessor: "id"accessor: "id",
})
.SchemaBuilder<Invoice, "id", never, {}>.column<"qty", "qty">(id: "qty", definition: AccessorColumnInput<Invoice, "qty", FormulaLikeReference<"qty", "id">, "id", never>): SchemaBuilder<Invoice, "id" | "qty", never, {}> (+2 overloads)column("qty", {
header?: LazyText | undefinedheader: "Qty",
accessor: "qty"accessor: "qty",
})
.SchemaBuilder<Invoice, "id" | "qty", never, {}>.column<"price", "unitPrice">(id: "price", definition: AccessorColumnInput<Invoice, "unitPrice", FormulaLikeReference<"price", "id" | "qty">, "id" | "qty", never>): SchemaBuilder<Invoice, "id" | "qty" | "price", never, {}> (+2 overloads)column("price", {
header?: LazyText | undefinedheader: "Unit Price",
accessor: "unitPrice"accessor: "unitPrice",
style?: CellStyle | StyleFn<Invoice> | undefinedstyle: { CellStyle.numFmt?: string | undefinednumFmt: "$#,##0.00" },
})
// Type-checked formula refs — row.ref("qty") must be declared before this column
.SchemaBuilder<Invoice, "id" | "qty" | "price", never, {}>.column<"subtotal">(id: "subtotal", definition: FormulaColumnInput<Invoice, FormulaLikeReference<"subtotal", "id" | "qty" | "price">, "id" | "qty" | "price", never>): SchemaBuilder<Invoice, "id" | "qty" | "price" | "subtotal", never, {}> (+2 overloads)column("subtotal", {
formula: FormulaFn<"id" | "qty" | "price", never>formula: ({ row: FormulaRowContext<"id" | "qty" | "price", never>row, fx: FormulaFunctions<"id" | "qty" | "price", never>fx }) =>
fx: FormulaFunctions<"id" | "qty" | "price", never>fx.FormulaFunctions<"id" | "qty" | "price", never>.round(value: FormulaValue<"id" | "qty" | "price", never>, decimals?: number): FormulaOperand<"id" | "qty" | "price", never>round(row: FormulaRowContext<"id" | "qty" | "price", never>row.FormulaRowContext<"id" | "qty" | "price", never>.ref(columnId: "id" | "qty" | "price"): FormulaOperand<"id" | "qty" | "price", never>ref("qty").FormulaOperand<"id" | "qty" | "price", never>.mul(right: FormulaValue<"id" | "qty" | "price", never>): FormulaOperand<"id" | "qty" | "price", never>mul(row: FormulaRowContext<"id" | "qty" | "price", never>row.FormulaRowContext<"id" | "qty" | "price", never>.ref(columnId: "id" | "qty" | "price"): FormulaOperand<"id" | "qty" | "price", never>ref("price")), 2),
style?: CellStyle | StyleFn<Invoice> | undefinedstyle: { CellStyle.numFmt?: string | undefinednumFmt: "$#,##0.00" },
summary?: SummaryInput<Invoice> | undefinedsummary: (s: SummaryBuilder<Invoice>s) => [s: SummaryBuilder<Invoice>s.SummaryBuilder<Invoice>.formula(formula: SummaryFormulaFunction | ((context: SummaryFormulaBuilderContext) => FormulaValue<string, never> | SummaryRowAggregateExpr), options?: Pick<SummaryDefinition<Invoice, unknown>, "format" | "style" | "conditionalStyle"> | undefined): SummaryDefinition<Invoice, undefined>formula("sum")],
})
// TypeScript fails if you reference a later column
.SchemaBuilder<Invoice, "id" | "qty" | "price" | "subtotal", never, {}>.column<"status", "status">(id: "status", definition: AccessorColumnInput<Invoice, "status", FormulaLikeReference<"status", "id" | "qty" | "price" | "subtotal">, "id" | "qty" | "price" | "subtotal", never>): SchemaBuilder<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}> (+2 overloads)column("status", {
accessor: "status"accessor: "status",
style?: CellStyle | StyleFn<Invoice> | undefinedstyle: (row: Invoicerow) => ({
CellStyle.font?: FontStyle | undefinedfont: {
FontStyle.bold?: boolean | undefinedbold: row: Invoicerow.status: "paid" | "pending" | "overdue"status === "overdue",
FontStyle.color?: ColorStyle | undefinedcolor: {
ColorStyle.rgb: stringrgb: row: Invoicerow.status: "paid" | "pending" | "overdue"status === "paid" ? "166534" : "B42318",
},
},
}),
})
.SchemaBuilder<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}>.build(): SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">build();
function createWorkbook(_options?: WorkbookOptions): PublicWorkbookcreateWorkbook()
.PublicWorkbook.sheet(name: string, options?: WorkbookSheetOptions): PublicWorkbookSheetsheet("Invoices", { SheetViewOptions.freezePane?: FreezePane | undefinedfreezePane: { FreezePane.rows?: number | undefinedrows: 1 } })
.PublicWorkbookSheet.table<SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">, undefined>(id: string, input: WorkbookReportTableOptions<SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">, undefined>): PublicWorkbookSheettable("invoices", { rows: Invoice[]rows, WorkbookReportTableInput<SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">, undefined>.schema: SchemaDefinition<Invoice, "id" | "qty" | "status" | "price" | "subtotal", never, {}, "report">schema });0
Runtime dependencies
Custom OOXML + ZIP engine — nothing to audit
2
Schema modes
Report layout or native Excel tables
60
Table style presets
Light, Medium & Dark tiers built in
4
Output targets
File, Buffer, Node stream & Web stream
Why typed-xlsx
The right primitives for
serious TypeScript reporting.
Type-safe schema
Declare columns against your row type. Accessors, path validation, sub-row expansion, and per-cell styling are all checked at compile time — shape drift is caught before export.
Formula DSL
Reference columns by ID, never by cell address. Forward references won't compile, so broken formula wiring is a TypeScript error — not a silent Excel bug.
Two schema modes
Emit classic report layouts or real Excel table objects complete with SUBTOTAL() totals, structured refs, and 60 built-in style presets.
Dynamic column groups
Generate columns from runtime data with fully inferred context. Missing or mistyped group context is a compile-time error, not a runtime surprise.
Streaming pipeline
Commit rows in batches through a spool-backed pipeline. Heap stays flat no matter the dataset size — with full schema parity to buffered mode.
Zero dependencies
Ships a custom OOXML serializer and incremental ZIP engine. No SheetJS, no ExcelJS — zero transitive risk in your dependency graph.
API surface
Three functions.
The whole library.
Schema
Typed columns, formulas & styles
createExcelSchema<T>(options?)
.column(id, {
accessor, formula,
style, summary,
validation,
})
.group(id, (group, ctx) => { ... })
// Array accessors expand rows automatically
.build()Workbook
Sheets, tables & output in one chain
createWorkbook()
.sheet(name, {
freezePane, rightToLeft,
tablesPerRow,
})
.table(name, {
schema, rows,
context, mode,
})
.writeToFile(path)Streaming
Same schema, unbounded datasets
const wb = createWorkbookStream();
const tbl = await wb
.sheet(name)
.table(name, { schema });
for await (const batch of cursor) {
await tbl.commit({ rows: batch });
}
await wb.writeToFile(path);Feature Surface
Depth where reports
actually get hard.
Formula DSL
Column IDs replace fragile cell addresses
Write formulas against column IDs and let the engine resolve final Excel coordinates. Rearranging columns is a layout decision, not a formula maintenance event.
// SheetJS: every formula is a string tied to a cell address
const r = dataStartRow + i;
ws[
`D${r}`
] = {
t: "n",
f: `=ROUND(B${r}*C${r},2)`,
z: "$#,##0.00",
};
// Insert a column before B?
// Re-audit every formula string by hand.
// Row type changed? No error. Wrong value at runtime.// Reference columns by ID — addresses resolved at build time
.column("subtotal", {
formula: ({ row, fx }) =>
fx.round(row.ref("qty").mul(row.ref("price")), 2),
// TypeScript error if "qty" or "price"
// aren't declared before this column
style: { numFmt: "$#,##0.00" },
summary: (s) => [s.formula("sum")],
});
// Move columns freely — formulas shift automatically.
// Row type changes fail before export.10 SheetJS-to-schema examples
Technical Blueprint
The Architectural
Monolith
Four layers, one coherent system. Each builds on the last — stop at any layer or use the full stack.
The Schema Layer
Model your worksheet directly from TypeScript row types. Accessors, selection, sub-rows, defaults, and styling live in a single declarative surface.
The Formula Engine
Compose Excel formulas from column IDs, not coordinates. Predecessor ordering is enforced at compile time — broken references never reach the spreadsheet.
The Workbook Builder
Assemble multi-sheet workbooks with report mode or native Excel tables, freeze panes, and deterministic table placement — all from a single fluent chain.
The Stream Pipeline
Flush large exports in batches through a spool-backed pipeline. The ZIP is assembled incrementally while the schema surface stays identical to buffered mode.
Showcase
Real outputs from
real schemas.
Typed pricing workflow
Deal Desk Quote Review
A quote approval workbook that models multi-line pricing, margin math, and approval flags without fragile cell-address code.
Scale Layer
One schema.
Two output paths.
Switch from buffered to streaming without touching your schema. Column definitions, formulas, summaries, validation, and table modes all carry over unchanged.
const wb = createWorkbook()
.sheet("Orders", { freezePane: { rows: 1 } })
.table("orders", { schema, rows });
await wb.writeToFile("./orders.xlsx");
// or: const bytes = wb.toBuffer()const wb = createWorkbookStream();
const tbl = await wb
.sheet("Orders")
.table("orders", { schema });
for await (const batch of db.cursor()) {
await tbl.commit({ rows: batch });
}
await wb.writeToFile("./orders.xlsx");Schema
Unchanged
Identical definition in both modes
Heap
Flat
Released after every batch commit
Dataset
Unbounded
Spool-backed incremental ZIP
Outputs
File · Buffer · Node · Web
Buffered: file/buffer. Streaming: file/Node/Web.
Entry Points
Pick your
starting point.
Engineered for TypeScript
Ready to
compile?
Define a schema, pass your rows, export a workbook. Your first report ships in under 30 lines — no configuration, no boilerplate.