Skip to main content

Errors

Every failure the library raises is an XlsxError with a stable code. Branch on the code; show the message, or your own copy.

import { isXlsxError } from '@jetstreamapp/simple-excel';

try {
const workbook = await openWorkbook(file);
} catch (error) {
if (isXlsxError(error)) {
switch (error.code) {
case 'ENCRYPTED':
return showPasswordHelp(error.message);
case 'LEGACY_XLS':
case 'XLSB':
case 'ODS':
return showConvertHelp(error.message);
default:
return showGenericFailure(error.message);
}
}
throw error;
}
class XlsxError extends Error {
readonly code: XlsxErrorCode;
readonly detail: Readonly<Record<string, unknown>> | undefined;
}

function isXlsxError(value: unknown): value is XlsxError;

detail carries whatever is useful for that code — the part name, the limit that was hit, the sniffed format, the sheet names that do exist. It is for logs and for your own messaging, not a stable API.

isXlsxError is a duck-typed check (instanceof, or name === 'XlsxError'), so it still works across realms — an error that crossed a worker boundary or came out of a bundled copy of the library.

Messages are for end users

The messages name the fix, in the words of somebody who has a spreadsheet open, not a stack trace:

This workbook is password-protected. Remove the password in Excel (File > Info > Protect Workbook) and save it again as .xlsx.

This is an Excel Binary Workbook (.xlsb). Open it in Excel and save it as .xlsx.

A cell holds 40000 characters, more than the 32767 Excel allows. Shorten it or write with cellOverflow: 'truncate'.

You can show them as they are. If you localize, branch on code.

Input format errors

These fire when the bytes are not a modern xlsx. The format is sniffed before any parsing, so the user is told what the file actually is instead of seeing a zip or XML failure.

CodeFires whenMessage intent
ENCRYPTEDA CFB container holding an EncryptedPackage stream — a password-protected workbookRemove the password in Excel and save again as .xlsx
LEGACY_XLSA CFB container without encryption: a BIFF .xls workbookIt is a legacy Excel 97-2003 file; re-save it as .xlsx
XLSBA zip whose main part is binary (.xlsb)It is an Excel Binary Workbook; re-save it as .xlsx
ODSA zip with an OpenDocument mimetype entryIt is an OpenDocument spreadsheet; open it and save as .xlsx
NOT_XLSXAnything else: CSV or other text, XML, HTML, .docx, .pptx, an empty file, a zip with no workbook partIt is not a spreadsheet, and which of those it looks like

NOT_XLSX carries detail.format'text', 'xml', 'html', 'empty', 'docx', 'pptx', 'zip', 'unknown' — so an application that has its own CSV parser can route on it rather than showing an error at all. sniff() lets you make that decision before calling openWorkbook.

The password-protected contract

The ENCRYPTED message always contains the literal words password-protected. That is a deliberate, tested contract, because applications (Jetstream among them) match on it. It will not change without a major version.

Why legacy formats are errors, not silent parses

SheetJS quietly parses .xls, .ods, SpreadsheetML 2003 and raw CSV bytes when they reach XLSX.read — which is convenient right up to the point where a user uploads a .xls, gets different type coercion than everyone else, and nobody can tell why. simple-excel supports .xlsx and .xlsm only (Strict OOXML is read-only) and names everything else (ADR-007). Several other libraries get the classification itself wrong: office-kit reports every CFB file, legacy .xls included, as encrypted, and fails on an .xlsb deep inside its XML parser.

Container errors

CodeFires when
ZIP_TRUNCATEDThe central directory or an entry runs past the end of the input, or a deflate stream ends early
ZIP_BOMBA part declares, or inflates to, more than limits.maxInflatedBytes, or blows past the ratio guard
ZIP_DUPLICATE_ENTRYTwo central-directory entries normalize to the same name
ZIP_UNSUPPORTEDAn entry uses a compression method other than stored or deflate, or is itself encrypted
ZIP_CRC_MISMATCHAn entry's bytes do not match the CRC-32 recorded for it

ZIP_DUPLICATE_ENTRY is a deliberate rejection rather than a guess. Readers disagree about duplicates — SheetJS takes the first entry, office-kit, openpyxl and calamine take the last (EC-ZIP-DUPLICATE-ENTRIES) — so the same file means different things depending on who opens it. That is exactly the shape of a smuggling bug, so it fails.

ZIP_CRC_MISMATCH is also stricter than some readers: SheetJS and office-kit do not check CRCs at all (EC-ZIP-CRC-MISMATCH). A stream raises it after its last chunk, so a partially consumed iteration may have yielded rows before the failure.

XML errors

CodeFires when
XML_DOCTYPEA <!DOCTYPE or an entity declaration appears in any part
XML_MALFORMEDStructurally broken XML in a part that had to be parsed

XML_DOCTYPE is not a parse failure — it is a refusal. Spreadsheet files never contain a DTD, and every entity expansion attack (billion laughs, XXE) needs one. The tokenizer knows the five predefined entities and numeric character references and nothing else, so there is no entity-expansion surface to guard in the first place (EC-XXE-DOCTYPE).

Limits

LIMIT_EXCEEDED covers every configurable cap, with the limit and the observed value in detail:

HitConfigured by
Too many parts in the archivelimits.maxEntries
Shared-string table too largelimits.maxSharedStringChars
XML nested too deeplimits.maxXmlDepth
One text node too longlimits.maxTextLength
A cell past the column cap for this readRowsOptions.maxColumns
More bytes than a memory sink allowscollectToBytes({ maxBytes })
A declared size beyond what can be addressed

Raising a limit is a deliberate act: the defaults are sized so a hostile upload fails fast rather than taking the process with it. See Reading.

Writer errors

CodeFires when
WRITER_STATEThe writer was used out of order: a second sheet opened while one is still open, a row written after close(), a sink written after it closed, a merge range that is not A1:C1-shaped, a single-cell merge
INVALID_SHEET_NAMEA sheet name that cannot be sanitized into something Excel accepts (a non-string)
ROW_OUT_OF_RANGEA row past 1,048,576, a row wider than 16,384 columns, or more column definitions than a sheet can hold
CELL_TOO_LONGA cell longer than 32,767 characters while cellOverflow: 'throw'
ENTRY_TOO_LARGEA zip entry or offset would overflow 32 bits while zip64 is off. The message names zip64: true as the fix

WRITER_STATE is almost always a sequencing mistake in calling code, and it is deliberately loud: a writer that carried on would produce a file Excel opens with its repair dialog. Sheet names are sanitized rather than rejected — see Writing — so INVALID_SHEET_NAME is rare.

Lookup and lifecycle

CodeFires when
SHEET_NOT_FOUNDworkbook.sheet(x) for a name or index that does not exist. The message lists the names that do
ABORTEDThe caller's AbortSignal fired, or abort() was called on the workbook writer or a sink
UNSUPPORTED_ENVIRONMENTA required platform feature is missing: collectToBlob() with no Blob, reading with no DecompressionStream

ABORTED is what a cancelled export or read throws; treat it as an expected outcome, not a bug. Note that a missing CompressionStream does not raise UNSUPPORTED_ENVIRONMENT on write — the writer falls back to storing parts uncompressed. Only reading genuinely requires DecompressionStream.

The full list

type XlsxErrorCode =
| 'NOT_XLSX'
| 'ENCRYPTED'
| 'LEGACY_XLS'
| 'XLSB'
| 'ODS'
| 'ZIP_TRUNCATED'
| 'ZIP_BOMB'
| 'ZIP_DUPLICATE_ENTRY'
| 'ZIP_UNSUPPORTED'
| 'ZIP_CRC_MISMATCH'
| 'XML_DOCTYPE'
| 'XML_MALFORMED'
| 'LIMIT_EXCEEDED'
| 'SHEET_NOT_FOUND'
| 'INVALID_SHEET_NAME'
| 'ROW_OUT_OF_RANGE'
| 'CELL_TOO_LONG'
| 'ENTRY_TOO_LARGE'
| 'WRITER_STATE'
| 'ABORTED'
| 'UNSUPPORTED_ENVIRONMENT';