@jgereg/excel-parser
v0.0.3
Published
Parse .xlsx files into typed objects with declarative schemas, transforms, and validation
Maintainers
Readme
excel-parser
Parse Excel (.xlsx) files into typed JavaScript objects with a declarative schema API.
Install
npm install @jgereg/excel-parserQuick start
users.xlsx (sheet: Users):
| Id | FirstName | LastName | Website | Address |
|----|-----------|----------|------------------------|--------------|
| 1 | Alice | Johnson | www.johnson-family.com | 123 Main St |
| 2 | Bob | Smith | http://www.smith.io | 456 Oak Ave |
| ...| ... | ... | ... | ... |import { readFileSync } from 'node:fs';
import { parseExcel } from '@jgereg/excel-parser';
const usersData = readFileSync('./users.xlsx');
const rows = parseExcel(usersData);
// [{ Id: 1, FirstName: 'Alice', LastName: 'Johnson', ... }, ...]Typed output with defineSchema
Describe the shape you want. Pass your Excel row type as the generic so field transforms are type-checked against the source columns.
| Id | FirstName | LastName |
|----|-----------|----------|
| 1 | Alice | Johnson |
| ...| ... | ... |
→ defineSchema
| id | fullName |
|----|---------------|
| 1 | Alice Johnson |
| ...| ... |import { readFileSync } from 'node:fs';
import { parseExcel, defineSchema } from '@jgereg/excel-parser';
type UserRow = {
Id: number;
FirstName: string;
LastName: string;
Website: string;
};
const usersData = readFileSync('./users.xlsx');
const schema = defineSchema<UserRow>()({
id: 'Id',
fullName: ({ row }) => `${row.FirstName} ${row.LastName}`,
});
const { rows, errors } = parseExcel(usersData, { schema });
// rows: [{ id: 1, fullName: 'Alice Johnson' }, { id: 2, fullName: 'Bob Smith' }, ...]
// errors: { sheet: [], rows: [] } when everything mapped successfullyWhen a schema is provided, parseExcel returns { rows, errors } instead of throwing. Rows that can be parsed are still returned. Errors are split by scope:
errors.sheet— sheet-wide problems, reported once (missing column, no header match)errors.rows— row-specific problems, reported per row (transform threw, validation failed)
Errors
type ParseSheetError = {
sheet: string;
field: string;
step?: number;
code: 'missing_column' | 'header_not_matched';
message: string;
};
type ParseRowError = {
sheet: string;
rowIndex: number;
field: string;
step?: number;
code: 'transform_failed' | 'validation_failed';
message: string;
row?: Record<string, unknown>; // parsed output when validation_failed
};
type ParseErrors = {
sheet: ParseSheetError[];
rows: ParseRowError[];
};| Code | Scope | When |
| -------------------- | ----- | ------------------------------------------------------- |
| missing_column | sheet | Column shorthand not in sheet headers — once per field |
| header_not_matched | sheet | matchHeader found no column — once per field |
| transform_failed | row | A transform threw — once per failing row |
| validation_failed | row | A required check failed — row is excluded from rows |
Failed fields are undefined on the row. Other fields on the same row still parse. When validation fails, the entire row is omitted from rows.
Validation
Mark a field with required on a column definition. Use true for a non-empty check, or pass a validator function.
type FieldValidator = (value: unknown, context: FieldContext) => boolean;
// column definition
{
column: 'Symbol',
required: true, // value must not be null, undefined, or ''
}
{
column: 'Symbol',
required: (value) => typeof value === 'string' && value.length <= 8,
}Validation runs after transforms. If any field fails, the row is skipped and errors.rows gets a validation_failed entry with the parsed row snapshot in row (so you can see what was rejected). message names the failing field.
| Symbol | Name | Price | ... |
|--------|------------|-------|-----|
| AAPL | Apple Inc. | 190 | ... |
| Note: Market values are indicative... | | |
→ defineSchema + validation
| symbol | gainLoss |
|--------|----------|
| AAPL | 4800 |
| (footer row excluded) |const { rows, errors } = parseExcel(reportsData, {
sheet: 'Q2 2025',
headerRow: 2,
schema: defineSchema<PortfolioRow>()({
symbol: {
column: 'Symbol',
required: (value) => typeof value === 'string' && value.length <= 8,
},
gainLoss: {
transform: ({ row }) => (row.Price - row['Cost Basis']) * row.Shares,
},
}),
});
// rows → 3 holdings (footer row excluded)
// errors.rows → [{ rowIndex: 3, field: 'symbol', code: 'validation_failed', row: { symbol: 'Note: ...', gainLoss: 0 }, ... }]| Id | FirstName | LastName |
|----|-----------|----------|
| 1 | Alice | Johnson |
→ defineSchema (Nickname column missing)
| id | nickname | firstName |
|----|-----------|-----------|
| 1 | undefined | Alice |const { rows, errors } = parseExcel(usersData, {
schema: defineSchema<UserRow>()({
id: 'Id',
nickname: 'Nickname', // column does not exist in sheet
firstName: 'FirstName',
}),
});
// rows[0] → { id: 1, nickname: undefined, firstName: 'Alice' }
// errors.sheet → [{ sheet: 'Users', field: 'nickname', code: 'missing_column', ... }]
// errors.rows → []Field definitions
Each output field accepts one of:
| Form | Example | Reads from |
| ------------------ | --------------------------------- | -------------------------------------------- |
| Column shorthand | 'Id' | Pipeline row (Excel row on the first step) |
| Transform function | ({ row }) => row.FirstName | Pipeline row |
| Transform object | { transform: ({ row }) => ... } | Pipeline row |
| Header matcher | { matchHeader, transform } | Pipeline row (column picked by matcher) |
| Transform chain | { transform: [step1, step2] } | Pipeline row |
Use matchHeader when column names are messy (extra spaces, inconsistent casing).
| Id | FirstName | Address |
|----|-----------|--------------|
| 1 | Alice | 123 Main St |
→ matchHeader + transform
| id | address |
|----|----------|
| 1 | Main St |import { defineSchema, type FieldContext } from '@jgereg/excel-parser';
const schema = defineSchema<UserRow>()({
id: 'Id',
address: {
matchHeader: ({ column }: FieldContext<UserRow>) => column!.trim().toLowerCase() === 'address',
transform: ({ value }) => String(value).replace(/\d/g, ''),
},
});Parse options
parseExcel(usersData, options?) accepts:
| Option | Default | Description |
| ----------- | ------- | ------------------------------------------------------------------- |
| sheet | 0 | Sheet index or name. 0 is the first sheet. |
| headerRow | 0 | Zero-based row index used as column headers for the selected sheet. |
| schema | — | Optional schema from defineSchema or defineSchemaSteps. |
cars.xlsx — two sheets:
Sheet "Cars" (headerRow: 0)
| Id | Make | Model | Year |
|----|--------|-------|------|
| 1 | Toyota | Camry | 2022 |
| ...| ... | ... | ... |
Sheet "Car Parts"
| Id | PartName | CarId | Price |
|----|------------|-------|-------|
| 1 | Brake Pads | 1 | 89.99 |
| ...| ... | ... | ... |reports.xlsx — quarterly portfolio export (title rows above the table headers):
Sheets: "Q1 2025", "Q2 2025", "Q3 2025" (each headerRow: 2)
| Portfolio Performance Report | row 0
| Period: Q2 2025 · As of 2025-06-30 · ... | row 1
| Symbol | Name | Shares | Price | Cost Basis | Market Value | row 2 — headers
| AAPL | Apple Inc. | 120 | 190.00 | 150.00/share | 22800 |
| ... | ... | ... | ... | ... |
| Note: Market values are indicative... (merged across columns) | footer row |Rows below the header row are parsed as data — including merged footer notes. The note text lands in the first column (Symbol); other columns are empty strings.
listSheets(reportsData); // ['Q1 2025', 'Q2 2025', 'Q3 2025']
parseExcel(usersData);
parseExcel(carsData, { sheet: 'Car Parts' });
parseExcel(reportsData, { sheet: 'Q2 2025', headerRow: 2 });listSheets reads sheet names only — no row parsing.
Each sheet can use its own headerRow. The row at that index becomes the header row; every row below it is parsed as data.
parseExcel(workbookData, { sheet: 'Cars' });
parseExcel(workbookData, { sheet: 'Car Parts', headerRow: 2 });Multi-step pipelines with defineSchemaSteps
Use steps when later fields depend on values computed in earlier steps.
Each step is a plain schema object. By default, it maps the fields you define and keeps the rest of the pipeline row — so later steps can read any column from row. Wrap a step in filterStep() when you want to output only the mapped fields (e.g. scratch steps or shaping the final result).
| Id | FirstName | LastName | Website |
|----|-----------|----------|------------------------|
| 1 | Alice | Johnson | www.johnson-family.com |
→ defineSchemaSteps
| id | dbId | email |
|---------|------|----------------------------------|
| user-1 | 1 | [email protected] |import { parseExcel, defineSchemaSteps, filterStep, type FieldContext } from '@jgereg/excel-parser';
const schema = defineSchemaSteps<UserRow>()([
// Step 1 — scratch fields (not in final output)
filterStep({
id: {
transform: ({ row }) => `user-${row.Id}`,
},
name: {
transform: ({ row }) => `${row.FirstName}_${row.LastName}`.toLowerCase(),
},
domain: {
matchHeader: ({ column }: FieldContext<UserRow>) =>
column!.trim().toLowerCase() === 'website',
transform: [
({ value }) => String(value).replace(/^[a-z]+:\/\//i, ''),
({ value }) => String(value).replace(/^www\./i, ''),
],
},
}),
// Step 2 — final output
filterStep({
id: ({ row }) => row.id, // value from step 1
dbId: ({ orig }) => orig.Id, // original Excel column
email: {
transform: ({ row }) => `${row.name}@${row.domain}`,
},
}),
]);
const { rows } = parseExcel(usersData, { schema });
// rows[0]: { id: 'user-1', dbId: 1, email: '[email protected]' }Filter steps with filterStep
By default, each pipeline step maps the fields you define and keeps the rest of the row unchanged — so later steps can read any column from row without using orig.
Wrap a step in filterStep() when you want to output only the mapped fields and drop everything else. Use this for scratch/intermediate steps or to shape the final output.
import { defineSchemaSteps, filterStep } from '@jgereg/excel-parser';
const schema = defineSchemaSteps<UserRow>()([
{
website: {
column: 'Website',
transform: [
({ value }) => String(value).replace(/^[a-z]+:\/\//i, ''),
({ value }) => String(value).replace(/^www\./i, ''),
],
},
},
filterStep({
id: 'Id',
email: ({ row }) => `${row.FirstName}@${row.website}`,
}),
]);
// Step 1 keeps Id, FirstName, … and adds normalized website
// Step 2 outputs only { id, email }const schema = defineSchemaSteps<UserRow>()([
filterStep({
id: { transform: ({ row }) => `user-${row.Id}` },
name: { transform: ({ row }) => `${row.FirstName}_${row.LastName}`.toLowerCase() },
domain: { column: 'Website', transform: normalizeDomain },
}),
filterStep({
id: ({ row }) => row.id,
email: ({ row }) => `${row.name}@${row.domain}`,
}),
]);row vs orig
Every transform receives the same context shape: { row, orig, column, value, index, sheet }.
| Source | How to read | Example |
| ------------------ | ------------------------------------------------------------ | ------------------------------------- |
| Pipeline row | 'ColumnName', { column: 'ColumnName' }, ({ row }) => … | id: 'Id', id: ({ row }) => row.id |
| Original Excel row | orig.ColumnName, { transform: ({ orig }) => ... } | dbId: ({ orig }) => orig.Id |
On the first step, row and orig are the same Excel row. From the second step onward, row holds prior step output and orig stays the Excel row. Use orig when you need a raw Excel column in a later step.
Apply a schema to existing rows with applySchema
Use applySchema when you already have raw row records and want to map them with a schema — without re-reading the workbook.
import { parseExcel, applySchema, defineSchema } from '@jgereg/excel-parser';
const rawRows = parseExcel(usersData);
const schema = defineSchema<UserRow>()({
id: 'Id',
fullName: ({ row }) => `${row.FirstName} ${row.LastName}`,
});
const { rows, errors } = applySchema(rawRows, schema, 'Users');sheet is used in error messages (errors.sheet, errors.rows). Pass the sheet name the rows came from.
API
listSheets(input): string[]
parseExcel(input, options?): Row[] | { rows: TypedRow[]; errors: ParseErrors }
applySchema(rows, schema, sheet): { rows: TypedRow[]; errors: ParseErrors }
defineSchema<TRow>()(fields): Schema
defineSchemaSteps<TRow>()([step1, step2, ...]): SchemaSteps
filterStep(fields): FilterStageparseExcel accepts an ArrayBuffer or Uint8Array (e.g. from readFileSync).
Parse options: sheet (default 0), headerRow (default 0), schema (optional).
Without a schema, returns raw row records. With a schema, returns { rows, errors: { sheet, rows } }.
TODO
Missing features, sorted by importance:
- Cross-sheet /
parseWorkbook()— Load all sheets in one read; join in app code (e.g.Cars+Car Parts). - Coercion — Defaults, Excel date serials →
Date, string numbers → number, empty cells →null. - Skip empty / junk rows — Ignore blank rows and trailing empty lines in exported sheets.
- Declarative cross-sheet lookup — e.g.
carMake: lookup('Cars', { local: 'CarId', foreign: 'Id' }). - Row filtering in schema — Skip rows during parse instead of filtering after.
- Async / streaming — For very large files.
- Cell range / footer trimming — Limit read range or drop trailing footer rows.
Scripts
npm run build # compile to dist/
npm run typecheck # tsc --noEmit
npm run test # jest