Comparison
SheetJS, ExcelJS, and typed-xlsx all produce valid .xlsx files. The difference is in the programming model: SheetJS and ExcelJS give you a cell-level API and leave report structure to you. typed-xlsx gives you a schema-driven builder where columns, formulas, styles, and summaries are declared once and type-checked at compile time.
Feature matrix
| Feature | SheetJS | ExcelJS | typed-xlsx |
|---|---|---|---|
| Programming model | Cell mutation | Row/cell builder | Schema-driven |
| TypeScript row safety | ❌ None | ❌ any rows | ✅ End-to-end |
| Typed accessors | ❌ | ❌ | ✅ |
| Formula DSL | ❌ String formulas | ❌ String formulas | ✅ Type-checked refs |
| Schema reusability | ❌ | ❌ | ✅ Stateless objects |
| Dynamic column groups | ❌ | ❌ | ✅ Inferred context |
| Column summaries | ⚠️ Manual | ⚠️ Manual | ✅ Built-in reducers |
| Native Excel tables | ⚠️ Raw XML | ❌ | ✅ First-class |
| Sub-row expansion | ⚠️ Manual | ⚠️ Manual | ✅ Automatic |
| Streaming writes | ⚠️ Limited | ⚠️ Limited | ✅ Full parity |
| Memory model (streaming) | ❌ Full dataset | ❌ Full dataset | ✅ Bounded by batch |
| Output targets | Buffer, file | Buffer, file, stream | Buffer, file, Node & Web stream |
| Charts | ❌ | ✅ | ❌ | | Image embedding | ❌ | ✅ | ❌ | | Rich text per cell | ⚠️ Partial | ✅ | ❌ | | Comments / notes | ✅ | ✅ | ❌ | | Dependencies | 0 (pro) / bundled | Several | 0 | | License | Apache 2.0 / Commercial | MIT | MIT |
✅ Supported ⚠️ Partial / manual ❌ Not supported
The core difference: cell API vs schema API
SheetJS and ExcelJS are cell APIs. You write individual cells and rows:
// SheetJS — you manage cell addresses and format strings manually
import * as XLSX from "xlsx";
const ws: XLSX.WorkSheet = {};
ws["A1"] = { v: "Invoice #", t: "s" };
ws["B1"] = { v: "Total", t: "s" };
ws["A2"] = { v: "INV-001", t: "s" };
ws["B2"] = { v: 2990, t: "n", z: "$#,##0.00" };
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Invoices");
XLSX.writeFile(wb, "invoices.xlsx");
// ExcelJS — row-by-row, cells by index
import ExcelJS from "exceljs";
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Invoices");
ws.columns = [
{ header: "Invoice #", key: "id", width: 14 },
{ header: "Total", key: "total", width: 14 },
];
ws.addRow({ id: "INV-001", total: 2990 });
await wb.xlsx.writeFile("invoices.xlsx");
// typed-xlsx — declare the schema once, pass rows
import { createExcelSchema, createWorkbook } from "@chronicstone/typed-xlsx";
type Invoice = { id: string; total: number };
const schema = createExcelSchema<Invoice>()
.column("id", { header: "Invoice #", accessor: "id", width: 14 })
.column("total", {
header: "Total",
accessor: "total",
width: 14,
style: { numFmt: "$#,##0.00" },
})
.build();
const workbook = createWorkbook();
workbook.sheet("Invoices").table("invoices", { rows: [], schema });
await workbook.writeToFile("invoices.xlsx");
The schema is a separate, stateless object. It isn't bound to a workbook, sheet, or any runtime state. You can pass it to ten different tables in ten different workbooks without any issue.
Type safety
This is where the gap is largest.
SheetJS
SheetJS has essentially no TypeScript safety on the data path. Cell addresses are strings. Cell values are any. If your column moves from B to C you update every reference by hand.
ExcelJS
ExcelJS ships TypeScript definitions, but they don't carry your row type. The addRow() call accepts any. Nothing prevents ws.addRow({ id: row.nmae }) — the typo compiles cleanly.
typed-xlsx
The schema is parameterized by your row type T. Every accessor is verified:
type Order = { orderId: string; amount: number; region: string };
const schema = createExcelSchema<Order>()
// "orderId" is checked against keyof Order — "orederId" would be a compile error
.column("id", { accessor: "orderId" })
// Callback has full type inference on `row`
.column("region", { accessor: (row) => row.region.toUpperCase() })
.build();
Formula references go further — referencing a column that doesn't exist yet is a TypeScript error at the definition site, before you run the build:
const schema = createExcelSchema<{ qty: number; price: number }>()
.column("qty", { accessor: "qty" })
.column("price", { accessor: "price" })
.column("total", {
// row.ref("qty") and row.ref("price") are type-checked
// row.ref("discount") would be a compile error here — "discount" isn't declared yet
formula: ({ row, fx }) => fx.round(row.ref("qty").mul(row.ref("price")), 2),
})
.build();
Formula columns
Neither SheetJS nor ExcelJS has a formula DSL. To write =ROUND(C2*D2,2) into a cell, you construct the string yourself and manage row numbers manually. When rows shift, your formulas break silently.
typed-xlsx emits formula strings for you from a composable DSL. You reference columns by their declared ID, not by their physical address:
.column("subtotal", {
formula: ({ row, fx }) => fx.round(row.ref("qty").mul(row.ref("unitPrice")), 2),
})
.column("tax", {
formula: ({ row, fx }) => fx.round(row.ref("subtotal").mul(0.2), 2),
})
.column("total", {
formula: ({ row }) => row.ref("subtotal").add(row.ref("tax")),
})
In report mode the library emits =ROUND(C2*D2,2), =ROUND(E2*0.2,2), =E2+F2 with the correct addresses. In excel-table mode it emits structured references: =ROUND([@Qty]*[@UnitPrice],2).
If you add a column between qty and unitPrice, all downstream formulas shift automatically. There is nothing to update manually.
Schema reusability and column selection
A common requirement: one canonical schema, but different exports show different columns depending on the audience — internal team sees cost data, external report hides it.
With SheetJS or ExcelJS you maintain two worksheet-building functions, or write conditional logic that manually skips cells and adjusts column indices.
With typed-xlsx the schema accepts a select option at table-build time:
// Full schema defined once
const schema = createExcelSchema<SalesRow>()
.column("region", { accessor: "region" })
.column("revenue", { accessor: "revenue" })
.column("cost", { accessor: "cost" }) // internal only
.column("margin", { accessor: "margin" }) // internal only
.build();
// External export — cost and margin excluded
workbook.sheet("External").table("sales", {
rows,
schema,
select: { exclude: ["cost", "margin"] },
});
// Internal export — full schema
workbook.sheet("Internal").table("sales", { rows, schema });
The select union is typed — "cost" and "margin" are checked against the schema's declared column IDs.
Dynamic column groups
Some reports require columns that are only known at runtime — one column per organization, one per month, one per category. typed-xlsx supports this with .group():
const schema = createExcelSchema<UserRow>()
.column("name", { accessor: "name" })
.group("orgs", (builder, orgs: Org[]) => {
for (const org of orgs) {
builder.column(`org-${org.id}`, {
header: org.name,
accessor: (row) => (row.memberOf.includes(org.id) ? "✓" : ""),
});
}
})
.build();
// Context shape is inferred — `{ orgs: Org[] }` is required, wrong type is a compile error
workbook.sheet("Users").table("users", {
rows,
schema,
context: { orgs: fetchedOrgs },
});
There is no equivalent in SheetJS or ExcelJS. Both require you to write the column-generation loop yourself and manually manage column indices.
Native Excel tables
An Excel <table> object is not just styling — it enables autoFilter, column totals via SUBTOTAL(), structured formula references, and correct sorting behavior in Excel desktop. Creating a real native table with SheetJS requires writing raw XML into the worksheet. ExcelJS has no support for it.
typed-xlsx treats native Excel tables as a first-class schema mode:
const schema = createExcelSchema<SalesRow>({ mode: "excel-table" })
.column("region", { header: "Region", accessor: "region" })
.column("revenue", {
header: "Revenue",
accessor: "revenue",
style: { numFmt: "$#,##0.00" },
totalsRow: { function: "sum" }, // SUBTOTAL(9, ...) in the totals row
})
.build();
workbook.sheet("Sales").table("sales", {
rows,
schema,
totalsRow: true,
style: "TableStyleMedium6",
});
60 built-in table styles. Totals row with 8 aggregate functions. Formulas emit structured references ([@Revenue]) compatible with Excel's table formula engine.
Streaming
SheetJS has a streaming write API, but it still requires you to produce a complete stream of worksheet data in one pass. ExcelJS's streaming writer similarly buffers significant worksheet state.
createWorkbookStream() serializes row batches to a file-backed spool as they arrive, then assembles the ZIP archive by streaming the spool data through a compressor. Peak memory is bounded by batch size:
const workbook = createWorkbookStream();
const table = await workbook.sheet("Orders").table("orders", { schema });
for await (const batch of databaseCursor()) {
await table.commit({ rows: batch }); // serialized and freed after each call
}
await workbook.writeToFile("./orders.xlsx");
The streaming builder has full feature parity with the buffered builder: formula columns, excel-table mode, groups, summaries, sub-row expansion, freeze panes. It is not a degraded mode.
Where SheetJS and ExcelJS win
typed-xlsx is a write-only library focused on report generation. It does not cover every spreadsheet use-case:
- Charts — ExcelJS can embed chart objects. typed-xlsx and SheetJS cannot.
- Images — ExcelJS can embed images into worksheets. typed-xlsx cannot.
- Rich text — ExcelJS supports rich text runs within a single cell. typed-xlsx does not.
- Comments / notes — Both SheetJS and ExcelJS support cell comments. typed-xlsx does not.
- Reading / parsing — SheetJS and ExcelJS can read existing
.xlsxfiles. typed-xlsx is write-only. - Multi-format support — SheetJS reads and writes CSV, ODS, XLSB, and other formats. typed-xlsx only writes
.xlsx.
If you need to parse incoming spreadsheets, generate charts, or embed images, SheetJS or ExcelJS is the right tool. typed-xlsx is designed for the opposite end: generating typed, schema-driven reports from structured data.
Summary
SheetJS and ExcelJS are general-purpose cell APIs. They give you complete control, at the cost of building your own report infrastructure on top.
typed-xlsx is the report infrastructure. If your data has a typed shape and your reports need consistent schemas, formulas, summaries, and styling — you define it once and the library handles the rest.
The TypeScript integration isn't cosmetic. The predecessor constraint on formula references, the inferred context shape for column groups, the typed column selection — these are guarantees that eliminate an entire class of runtime bugs that you'd otherwise only discover by opening the exported file.