Typed-xlsx
@chronicstone/typed-xlsx

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.

Without typed-xlsx
// 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.
With typed-xlsx
// 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.

01

The Schema Layer

Model your worksheet directly from TypeScript row types. Accessors, selection, sub-rows, defaults, and styling live in a single declarative surface.

typed accessorsselectionsub-rowsstylesdefaults
02

The Formula Engine

Compose Excel formulas from column IDs, not coordinates. Predecessor ordering is enforced at compile time — broken references never reach the spreadsheet.

row.ref()fx.*group sumssummary formulascompile-time safety
03

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.

report modeexcel tablesmulti-sheetlayoutfreeze panes
04

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.

batch commitspoolincremental ZIPNode streamsWeb streams

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.

sub-row expansionformula DSLtyped predecessor refs
2 sheets Open playground

Multi-sheet composition

Executive Board Pack

A polished leadership workbook that turns typed customer portfolio data into board-ready views and watchlists.

buffered workbookmulti-sheet compositionmulti-table layout
2 sheets Open playground

Logical vs physical rows

Logical vs Physical Summary

A compact workbook that makes logical rows, physical rows, and row-aware summary formulas visible in one place.

sub-row expansionrow.series aggregateslogical row summaries
1 sheet Open playground

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.

Bufferedup to ~50k rows
const wb = createWorkbook()
  .sheet("Orders", { freezePane: { rows: 1 } })
  .table("orders", { schema, rows });

await wb.writeToFile("./orders.xlsx");
// or: const bytes = wb.toBuffer()
Streamingunbounded — heap stays flat
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.

Build your first report

Typed accessors, formula refs, a summary row, and a freeze pane — in under 30 lines of code.

Quick start

Explore the schema API

Columns, formulas, groups, sub-rows, styling, and validation — the full schema surface explained.

Schema builder

Compare to SheetJS / ExcelJS

Type safety, formula DSL, native tables, and schema reuse — side by side with the two most popular alternatives.

Library comparison

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.

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