A fluent TypeScript library for parsing and validating product import CSV files. Define rules for each product type, then run your CSV through them — groups are validated, errors are reported per-row with context.
Row groups — Rows are grouped by handle. The first row for a handle is the head row; subsequent rows sharing that handle (or rows with a blank handle immediately following) are continuation rows.
Product definitions — You define what a valid group looks like for each product type: which fields are required on the head row, and what kinds of continuation rows are allowed.
Continuation row types — Each continuation row type has a matchWhen predicate, required fields, and forbidden fields. The first matching type wins.
bun add csv-import-lib # when published
# or copy src/ directly into your projectimport {
CsvImporter,
ProductDefinition,
ContinuationRow,
} from "csv-import-lib";
const importer = new CsvImporter();
// Simple products — no continuation rows allowed
importer.addDefinition(
ProductDefinition.create("simple")
.matchWhen((row) => row.type === "simple")
.requireFields(["handle", "title", "type", "sku"]),
);
// Variable products — each variant is a continuation row
importer.addDefinition(
ProductDefinition.create("variable")
.matchWhen((row) => row.type === "variable")
.requireFields([
"handle",
"title",
"type",
"sku",
"option 1 name",
"option 1 value",
])
.allowContinuationRows(
ContinuationRow.create()
.label("variant-row")
.matchWhen((row) => !!row.sku)
.requireFields(["handle", "sku", "option 1 value"])
.forbidFields(["title"]),
),
);
// Serialized products — each IMEI is a continuation row
importer.addDefinition(
ProductDefinition.create("serialized")
.matchWhen((row) => row.type === "serialized")
.requireFields(["handle", "title", "type", "sku", "imei"])
.allowContinuationRows(
ContinuationRow.create()
.label("imei-row")
.matchWhen((row) => !!row.imei && !row.sku)
.requireFields(["handle", "imei"])
.forbidFields(["title", "type", "sku", "price", "cost"]),
),
);
const result = importer.parse(csvString);
if (result.ok) {
for (const group of result.valid) {
console.log(
group.handle,
group.definition,
group.head,
group.continuations,
);
}
} else {
for (const error of result.errors) {
console.error(
`Line ${error.line} [${error.handle}]${error.field ? ` field:${error.field}` : ""}: ${error.message}`,
);
}
}The library expects a header row followed by data rows. The handle column groups rows into products.
handle,title,description,type,quantity,price,cost,sku,imei,barcode,category,tags,status,option 1 name,option 1 value,option 2 name,option 2 value,option 3 name,option 3 value,image src
simple-product,My Product,A description,simple,5,9.99,5.00,SKU-001,,,Electronics,sale,active,,,,,,,
variable-product,My Variable,A description,variable,10,9.99,5.00,SKU-002,,,Clothing,,active,Color,Red,,,,,
variable-product,,,,8,9.99,5.00,SKU-003,,,,,,,Blue,,,,,
serialized-product,My Serialized,A description,serialized,1,199,99,SKU-004,123456789012,,,,,,,,,,,
serialized-product,,,,,,,,987654321098,,,,,,,,,,,Continuation rows can either repeat the handle or leave it blank — both are treated as belonging to the previous group.
Creates a new importer instance. addDefinition() returns this so you can chain:
const importer = new CsvImporter()
.addDefinition(...)
.addDefinition(...)Registers a product definition. Definitions are evaluated in registration order — the first whose matchWhen predicate returns true for a group's head row will own that group.
Parses and validates the CSV. Returns:
interface ParseResult {
ok: boolean; // true if zero errors
valid: ValidGroup[]; // groups that passed validation
errors: ValidationError[];
}
interface ValidGroup {
definition: string; // name of the matched definition
handle: string;
head: RawRow;
continuations: RawRow[];
}
interface ValidationError {
handle: string;
line: number;
field?: ProductRowField; // set when the error is field-specific
message: string;
}Starts a new product definition builder.
| Method | Description |
|---|---|
.matchWhen(fn) |
Predicate against the head row. First match wins. |
.requireFields(fields[]) |
Fields that must be non-empty on the head row. |
.allowContinuationRows(builder) |
Register a continuation row type. Can be called multiple times for multiple types. |
Starts a new continuation row type builder.
| Method | Description |
|---|---|
.label(name) |
Human-readable name used in error messages. |
.matchWhen(fn) |
Predicate to identify this row type. First match wins. |
.requireFields(fields[]) |
Fields that must be non-empty. |
.forbidFields(fields[]) |
Fields that must be empty. |
These are the column names recognized by ProductRow:
| Field | Field | Field |
|---|---|---|
handle |
title |
description |
type |
quantity |
price |
cost |
sku |
imei |
barcode |
category |
tags |
status |
option 1 name |
option 1 value |
option 2 name |
option 2 value |
option 3 name |
option 3 value |
image src |
All field names are typed as ProductRowField — passing an invalid field name to requireFields or forbidFields will be caught at compile time.
bun run vitest