npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jgereg/excel-parser

v0.0.3

Published

Parse .xlsx files into typed objects with declarative schemas, transforms, and validation

Readme

excel-parser

Parse Excel (.xlsx) files into typed JavaScript objects with a declarative schema API.

Install

npm install @jgereg/excel-parser

Quick 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 successfully

When 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): FilterStage

parseExcel 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:

  1. Cross-sheet / parseWorkbook() — Load all sheets in one read; join in app code (e.g. Cars + Car Parts).
  2. Coercion — Defaults, Excel date serials → Date, string numbers → number, empty cells → null.
  3. Skip empty / junk rows — Ignore blank rows and trailing empty lines in exported sheets.
  4. Declarative cross-sheet lookup — e.g. carMake: lookup('Cars', { local: 'CarId', foreign: 'Id' }).
  5. Row filtering in schema — Skip rows during parse instead of filtering after.
  6. Async / streaming — For very large files.
  7. 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