Modern TypeScript
Excel reader & writer
Read and write real .xlsx workbooks from a single ESM-first document model — an ExcelJS-shaped rewrite with a low-memory streaming writer for exports too big to buffer. One runtime dependency, full type declarations.
Write, then read it back
# Install
npm install @alosha/xlsx
import { Workbook } from '@alosha/xlsx'
const workbook = new Workbook()
const sheet = workbook.addWorksheet('Report')
sheet.getCell('A1').value = 'Item'
sheet.getCell('B1').value = 'Qty'
sheet.getCell('B2').value = 42
sheet.getCell('B2').numFmt = '#,##0'
sheet.mergeCells('A4:B4')
// Browser-safe — never touches node:fs.
const bytes: Uint8Array = await workbook.xlsx.writeBuffer()
// Node convenience wrapper.
await workbook.xlsx.writeFile('report.xlsx')
// Read it back.
const loaded = new Workbook()
await loaded.xlsx.load(bytes)
loaded.getWorksheet('Report')?.getCell('B2').value // → 42Everything a real workbook needs
- ExcelJS-shaped APInew Workbook(), addWorksheet, getCell, getRow and the workbook.xlsx.writeBuffer/writeFile/load/readFile accessor all behave the way ExcelJS code already expects.
- Drop-in @alosha/xlsx/compatSwap the import — `import ExcelJS from "@alosha/xlsx/compat"` — and most of an ExcelJS codebase keeps working unchanged: same cell.type numbers, same style-setter precedence, same addRow/row.values behaviour. A few behaviours differ (a Uint8Array instead of a Node Buffer, a 0-based worksheets array, a couple of deferred features) — the migration guide lists them.
- Styles, number formats & mergesFonts, fills, borders, alignment, protection and number formats round-trip on read and write, with mergeCells and a useStyles toggle for lean exports.
- Freeze panes & auto-filterworksheet.views for frozen/split panes and worksheet.autoFilter for the header-row filter range both round-trip as data, not just on write.
- Conditional formattingcellIs, expression, the text-contains family, top10, aboveAverage/belowAverage, duplicateValues/uniqueValues, plus self-visualising colorScale, dataBar and iconSet rules.
- Data validationList dropdowns and whole/decimal/date/time/text-length/custom constraints, settable per cell or per range via worksheet.dataValidations.add(sqref, dv).
- Cell commentscell.note round-trips plain-string and rich-text notes with a multi-author table — real comments{n}.xml plus the legacy VML anchor Excel expects.
- Embedded imagesworkbook.addImage + worksheet.addImage support two-cell range anchors and one-cell/pixel-extent anchors for logos, charts-as-images and photo grids.
- Low-memory streaming writerThe stream.xlsx.WorkbookWriter-shaped API renders and deflates each row as you commit it — ~14.2× less peak memory than the buffered writer at ~1.9× the throughput on 500k-row exports.
Why teams move off ExcelJS or a home-grown OOXML writer
Large exports blow up memory on the buffered path
A finance or reporting export with hundreds of thousands of rows built as one in-memory workbook can push a Node process into GC thrash or an OOM kill, right when the report matters most.
What you take on building or maintaining it yourself
- Peak memory
Holding every row, style and shared string in memory before serializing means peak RSS scales linearly with row count.
- Time to first byte
Nothing streams to the client until the entire workbook has been built and zipped, so large exports feel frozen.
- Container limits
A report that worked in dev OOMs in a memory-capped serverless function or container once real production data volume hits it.
With @alosha/xlsx: The streaming WorkbookWriter renders and deflates each row into the output archive the moment you call row.commit(), keeping peak memory roughly flat regardless of row count — with the same styles, merges, panes, data validation, conditional formatting, comments and images as the buffered writer.
Porting off ExcelJS usually means a rewrite, not a swap
Most alternatives to ExcelJS have a different API shape, so migrating existing spreadsheet code means touching every call site — a project few teams have time to start.
What you take on building or maintaining it yourself
- API drift
Different constructor, cell-access and serialization surfaces mean every getCell/addRow/style call needs to be rewritten and re-tested.
- Silent behaviour changes
Subtle differences — style-setter merge vs replace semantics, cell.type numbering — can pass a smoke test and still corrupt output in production.
- Migration cost
A full rewrite of the spreadsheet layer competes with every other roadmap item, so teams stay on an unmaintained dependency instead.
With @alosha/xlsx: @alosha/xlsx/compat is built to be a same-behaviour drop-in — matching cell.type numbers, ExcelJS's whole-category style-setter precedence, and the addRow/row.values/worksheet.columns contract — so most codebases port with a single import-line change, not a rewrite.
Peak memory: buffered vs streaming writer
Representative peak memory writing 500,000 rows × 5 columns. Streaming keeps memory roughly flat regardless of row count; buffered scales with it.
≈2.7 GiB peak — scales with row count
≈14.2× less peak memory, ~1.9× the throughput
Production recipes
Swap ExcelJS for @alosha/xlsx without touching the rest of the file
The problem: An existing report generator is built on ExcelJS, but the dependency has gone quiet and a full rewrite of every getCell/style call isn't on the roadmap.
- import ExcelJS from "exceljs"
+ import ExcelJS from "@alosha/xlsx/compat"
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('Report')
sheet.getCell('A1').value = 'Item'
sheet.getColumn(2).width = 12
sheet.getCell('B1').font = { bold: true }
await workbook.xlsx.writeFile('report.xlsx')Why it works: @alosha/xlsx/compat backs the same new Workbook()/addWorksheet/getCell surface with @alosha/xlsx's model and writer, matching ExcelJS's cell.type numbers and whole-category style-setter precedence — so everything below the import line keeps working unchanged.
Export 500,000 rows without holding the workbook in memory
The problem: A nightly export job builds a report so large that the buffered writer's in-memory workbook risks OOM-killing the container it runs in.
import { WorkbookWriter } from '@alosha/xlsx'
const wb = new WorkbookWriter({ filename: 'big-report.xlsx' })
const ws = wb.addWorksheet('Data')
for (const record of hugeDataset) {
ws.addRow([record.id, record.name, record.amount]).commit() // flushed now
}
ws.commit()
await wb.commit()Why it works: row.commit() renders and deflates that row into the output archive immediately instead of retaining it, so peak memory stays roughly flat regardless of row count — about 14.2× less peak memory than the buffered writer at 1.9× the throughput on a 500k-row benchmark. Pass a Writable instead of a filename to stream straight to an HTTP response, or omit both to consume the writer as an AsyncIterable<Uint8Array>.
Write a formula, rich text, a hyperlink and an error code — all as plain cell values
The problem: A dashboard row needs a cached formula result, mixed-format text in one cell, a clickable link and a native #DIV/0! — without reaching for a different API per cell type.
const ws = workbook.addWorksheet('Dashboard')
const row = ws.addRow([])
// Formula with a cached result
row.getCell('A').value = { formula: 'SUM(B2:B10)', result: 420 }
// Inline rich text within a single cell
row.getCell('B').value = {
richText: [
{ text: 'Status: ', font: { bold: true } },
{ text: 'Critical Failure', font: { color: { argb: 'FFFF0000' } } }
]
}
// Hyperlink with a tooltip
row.getCell('C').value = {
text: 'Open Issue',
hyperlink: 'https://github.com/avlisodraude/alosha-xlsx/issues/1',
tooltip: 'Click to view issue tracker'
}
// Native Excel error code
row.getCell('D').value = { error: '#DIV/0!' }Why it works: cell.value is a TypeScript discriminated union, so every advanced shape — formula, richText, hyperlink, error — is just an object you assign, with autocomplete narrowing the fields for each. There is no per-type setter to memorise, and each shape round-trips through readWorkbookBuffer as the same object you wrote.
Append hundreds of rows that inherit the header's fonts and borders
The problem: A ledger export re-declares the same font and border on every row of the loop, because porting ExcelJS's cryptic i / o / i+ inheritance flags never felt worth it.
const ws = workbook.addWorksheet('Ledger')
// Style a baseline row once
const header = ws.addRow(['Date', 'Account', 'Amount'])
header.font = { bold: true, size: 12 }
header.border = { bottom: { style: 'medium' } }
// New rows inherit the row above's structure — no re-styling per iteration
ws.addRow(['2026-07-08', 'Operating Costs', 1250.0], { inheritFrom: 'above' })
ws.addRow(['2026-07-09', 'SaaS Licensing', 450.0], { inheritFrom: 'above' })Why it works: inheritFrom takes a readable { inheritFrom: "above" | "below", includeEmpty? } object instead of ExcelJS's i / o / i+ / o+ DSL, and copies the source row's style (and, with includeEmpty, its per-cell styles) — so a long write loop stays declarative without re-setting fonts and borders each pass.
Generate a workbook synchronously, with a tree-shakeable import
The problem: An edge function or a bundle-size-sensitive frontend needs to emit a spreadsheet without the overhead of an async accessor or pulling the whole library into the bundle.
import { Workbook, writeWorkbookBuffer, readWorkbookBuffer } from '@alosha/xlsx'
const wb = new Workbook()
wb.addWorksheet('SyncReport').getCell('A1').value = 'Instant Export'
// Pure, synchronous serialization — returns a Uint8Array
const bytes: Uint8Array = writeWorkbookBuffer(wb)
// Read it back, also synchronously
const roundTripped = readWorkbookBuffer(bytes)Why it works: writeWorkbookBuffer/readWorkbookBuffer are the environment-agnostic core the workbook.xlsx.* accessor wraps — fully synchronous (no promise/microtask overhead) and tree-shakeable, so a bundler drops the reader when you only write. Neither touches node:fs, so the same call runs in a browser, worker or edge runtime.
Drop a logo or chart image into a report
The problem: An invoice or branded report needs an embedded image placed precisely — either spanning a block of cells or pinned at an exact pixel size.
// Intern the raw bytes once on the workbook
const imageId = workbook.addImage({ buffer: companyLogoBytes, extension: 'png' })
const ws = workbook.addWorksheet('Invoice')
// Option A: span a two-cell range
ws.addImage(imageId, 'A1:C3')
// Option B: pin a top-left cell with an explicit pixel size
ws.addImage(imageId, { tl: { col: 5, row: 1 }, ext: { width: 180, height: 60 } })Why it works: workbook.addImage interns the bytes once and returns a numeric id, so the same image placed on several sheets is stored only once. worksheet.addImage accepts either a two-cell range string or a one-cell pixel-extent anchor — the two placement forms ExcelJS reports covering in day-to-day use.
Convert between A1 strings and row/column indexes without hand-rolling the math
The problem: A dynamic sheet builder keeps re-implementing "turn (row, col) into C10" and "how many rows does B2:G15 span" — fiddly code that's easy to get subtly wrong.
import { colLetterToNumber, colNumberToLetter, encodeAddress, decodeAddress, Range } from '@alosha/xlsx'
colLetterToNumber('AA') // 27
colNumberToLetter(5) // 'E'
encodeAddress(10, 3) // 'C10' (args are row, col)
decodeAddress('C10') // { row: 10, col: 3, address: 'C10' }
const range = new Range('B2:G15')
range.range // 'B2:G15'
range.tl // 'B2' top-left address
range.br // 'G15' bottom-right address
range.top // 2 first row
range.left // 2 first column (B)
range.count // 84 total cells
range.bottom - range.top + 1 // 14 row countWhy it works: The same cached A1 codecs the library uses internally are exported, so an index-to-address conversion is one call instead of a bespoke helper. Range normalises any A1 range into 1-based top/left/bottom/right bounds with tl/br/range/count accessors — construct it with new Range('B2:G15') or new Range(top, left, bottom, right).
Highlight out-of-range values with conditional formatting
The problem: A QA dashboard needs failing measurements to visually stand out, without a client-side pass recomputing which cells are out of spec on every render.
const ws = workbook.addWorksheet('Measurements')
ws.addConditionalFormatting({
ref: 'B2:B500',
rules: [
{ type: 'cellIs', operator: 'greaterThan', formulae: [100], style: { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } } } },
{ type: 'colorScale', cfvo: [{ type: 'min' }, { type: 'max' }], color: ['FFF8696B', 'FF63BE7B'] }
]
})Why it works: Conditional formatting rules are written straight into the sheet's <conditionalFormatting> blocks with differential styles deduped into a shared <dxfs> pool, so Excel and LibreOffice recompute the highlighting themselves from the live cell values — no client-side re-derivation, and the rules survive a round-trip through readWorkbookBuffer.
Constrain input to a dropdown list with a same-sheet data validation
The problem: A generated order-entry template needs a status column restricted to a fixed set of values, enforced by Excel itself, not just documented in a comment.
const ws = workbook.addWorksheet('Orders')
ws.dataValidations.add('C2:C200', {
type: 'list',
allowBlank: true,
showInputMessage: true,
showErrorMessage: true,
formulae: ['"Pending,Shipped,Cancelled"']
})Why it works: The <dataValidations> block is emitted in the correct CT_Worksheet position, so Excel renders a real in-cell dropdown and rejects free-text input on that range — enforcement lives in the file itself, so it holds even if the workbook is edited outside your app.
Freeze the header row and add an auto-filter in one pass
The problem: A generated report is unreadable once scrolled — the header row disappears and there's no way to filter columns without manually re-adding both in Excel after every export.
const ws = workbook.addWorksheet('Report')
ws.views = [{ state: 'frozen', xSplit: 0, ySplit: 1, topLeftCell: 'A2' }]
ws.autoFilter = 'A1:F1'Why it works: views and autoFilter are real worksheet state, not writer-only options — they round-trip on read the same way ExcelJS's do, so a workbook you generate and then re-open programmatically still reports the frozen pane and filter range exactly as written.
Attach a reviewer note to a cell without a full comments library
The problem: A generated audit spreadsheet needs to flag specific cells with a reviewer's explanation, in a form Excel's native comment popup understands — not a sidecar column.
const ws = workbook.addWorksheet('Audit')
ws.getCell('D12').note = {
texts: { richText: [{ text: 'Flagged: ', font: { bold: true } }, { text: 'exceeds threshold by 12%' }] },
author: 'Compliance'
}Why it works: cell.note writes a real comments{n}.xml note/author table plus the legacy VML anchor Excel's comment UI expects, so the note shows up as a native comment bubble with the author attributed — no sidecar sheet or custom popover to build and keep in sync.
Built to pass a dependency review
| Metric / concern | What ships |
|---|---|
| Dependencies | 1 runtime dep (fflate) |
| Type safety | Full .d.ts, ESM + CJS |
| Streaming writer | ~14.2× less peak memory |
| ExcelJS parity | @alosha/xlsx/compat drop-in |
| Feature coverage | Styles, CF, validation, comments, images |
| Runtime targets | Node ≥ 22, browser, edge |
| Licensing | MIT |
Need more than the open-source core?
- Migration help porting a large ExcelJS codebase onto @alosha/xlsx/compat
- Guidance wiring the streaming writer into an existing export pipeline
- Priority bug fixes and answers straight from the maintainer