sdxc

[ Content & formats ]

@sdxc/csv

Read and write RFC 4180 CSV, with a streaming writer and formula neutralization

npm add @sdxc/csv
pnpm add @sdxc/csv
yarn add @sdxc/csv
bun add @sdxc/csv
Installs with
@sdxc/result
Used by
uptime, reader

Read and write RFC 4180 CSV, with a streaming writer and formula neutralization.

parse reads text into records, stringify writes rows as one string, and streamify writes them as a UTF-8 byte stream. A namespace import reads CSV.parse(...) like JSON.parse, and every function returns a Result where JSON would throw.

Installation

npm add @sdxc/csv

Results come from @sdxc/result, which installs alongside it and provides unwrap and isFailure.

Usage

Write and read back

import * as CSV from "@sdxc/csv";
import { unwrap } from "@sdxc/result";

let text = unwrap(CSV.stringify([{ name: "Ana", city: "Málaga" }]));
// "name,city\r\nAna,Málaga\r\n"

let rows = unwrap(CSV.parse(text)).rows;
// [{ name: "Ana", city: "Málaga" }]

Read an upload

import { parse } from "@sdxc/csv";
import { isFailure } from "@sdxc/result";

let parsed = parse(await file.text());
if (isFailure(parsed)) {
	return new Response(parsed.error.message, { status: 400 }); // "Expected 3 fields, found 2 at line 12"
}

parsed.data.rows; // Record<string, string>[]
parsed.data.warnings; // [{ line, message }]

Every field reads as a string: CSV has no numbers, dates or nulls, so your own code types the rows.

Choose columns and write typed cells

import { stringify } from "@sdxc/csv";

let result = stringify(
	[{ id: 1n, createdAt: new Date("2026-10-12T09:30:00Z"), total: 19.5, note: null }],
	{
		columns: [
			{ key: "id", header: "Order" },
			{ key: "createdAt", header: "Created (UTC)" },
			{ key: "total" },
			{ key: "note" },
		],
	},
);
// "Order,Created (UTC),total,note\r\n1,2026-10-12T09:30:00.000Z,19.5,\r\n"

columns sets the order and header text; without it the first row's keys are the columns.

Stream a download

import { streamify } from "@sdxc/csv";

let body = streamify(rows, { columns: [{ key: "date" }, { key: "visits" }] });

return new Response(body, {
	headers: {
		"Content-Type": "text/csv; charset=utf-8",
		"Content-Disposition": 'attachment; filename="visits.csv"',
	},
});

API

parse(source: string, options?: ParseOptions)

Returns Result<Parsed<Record<string, string>>, CSVParseError>, or Parsed<string[]> with header: false. Records may end in CRLF, LF or CR; a leading BOM is dropped, blank lines are skipped, and a bare " inside an unquoted field is kept and reported in warnings.

  • delimiter: "," (default), ";" or "\t"

  • header: read the first record as keys (default true)

stringify(rows, options?: StringifyOptions)

Writes objects under a header record, or arrays of cells as bare records, and returns Result<string, CSVStringifyError>. Fields holding the delimiter, a quote, a line break or edge spaces are quoted, and every record ends in CRLF.

  • columns: { key, header? }[], the order and header text; the first row's keys otherwise

  • header: write the header record (default true)

  • delimiter: "," (default), ";" or "\t"

  • bom: prefix a UTF-8 BOM, which Excel needs to read the file as UTF-8 (default false)

  • escapeFormulas: prefix ' to string cells starting with =, +, -, @, tab or CR, so a spreadsheet shows them as text (default true)

streamify(rows, options: StringifyOptions & { columns })

Writes an Iterable or AsyncIterable of objects as a ReadableStream<Uint8Array> holding the text stringify would. The header goes out before the first row is read, rows go out in chunks of about 16 KB, cancelling stops the source iterator, and a bad cell errors the stream with a CSVStringifyError.

CSVParseError

Carries the 1-based line of an unterminated quoted field, text after a closing quote, a record whose field count differs from the first record's, or a duplicate header.

CSVStringifyError

Carries the row index and column of a value outside the cell types, an invalid Date or a non-finite number; row is absent when the delimiter is unknown.

Types

Cell is string | number | bigint | boolean | Date | null | undefined: null and undefined write an empty field and a Date writes ISO 8601 in UTC. CellRecord<Row> accepts rows typed by an interface, Column<Row> is one { key, header? }, and Delimiter is "," | ";" | "\t". ParseOptions, Parsed<T> (rows and warnings), ParseWarning (line and message), WriteOptions and StringifyOptions<Row> name the options and results above.

Pattern: Type Uploaded Rows

A check per row turns string records into the values your code works with and collects the rows it rejects.

import { parse } from "@sdxc/csv";
import { isFailure } from "@sdxc/result";

interface Subscriber {
	email: string;
	website: URL;
	seats: number;
}

let parsed = parse(source);
if (isFailure(parsed)) throw parsed.error;

let subscribers: Subscriber[] = [];
let problems: string[] = [];

for (let [index, row] of parsed.data.rows.entries()) {
	let seats = Number(row.seats);
	if (!row.email || !URL.canParse(row.website) || !Number.isInteger(seats)) {
		problems.push(`Row ${index + 2} is incomplete`); // the header is line 1
		continue;
	}
	subscribers.push({ email: row.email, website: new URL(row.website), seats });
}

Pattern: Export From A Paged Query

streamify reads an async iterable, so an export streams straight from a paged query while holding one page in memory.

import { streamify } from "@sdxc/csv";

async function* orders(db: Database) {
	let cursor: string | undefined;
	do {
		let page = await db.orders.list({ after: cursor, limit: 500 });
		yield* page.rows;
		cursor = page.next;
	} while (cursor);
}

let body = streamify(orders(db), {
	columns: [
		{ key: "id", header: "Order" },
		{ key: "placedAt", header: "Placed at" },
		{ key: "total", header: "Total" },
	],
});

return new Response(body, { headers: { "Content-Type": "text/csv; charset=utf-8" } });

Pattern: A File Excel Opens Correctly

Excel in decimal-comma locales (Spanish, German, French, Italian) splits a .csv on ;, and reads it as UTF-8 only when it starts with a BOM. Formula neutralization stays on, so a cell like =HYPERLINK(...) from user input shows as text.

import { stringify } from "@sdxc/csv";
import { unwrap } from "@sdxc/result";

let text = unwrap(
	stringify(rows, {
		columns: [
			{ key: "date", header: "Fecha (UTC)" },
			{ key: "name", header: "Nombre" },
		],
		delimiter: ";",
		bom: true,
	}),
);

Pattern: Headerless Records And TSV

With header: false the reader returns arrays of fields, and the writer takes arrays of cells.

import { parse, stringify } from "@sdxc/csv";
import { unwrap } from "@sdxc/result";

let records = unwrap(parse("a\t1\r\nb\t2", { header: false, delimiter: "\t" })).rows;
// [["a", "1"], ["b", "2"]]

let tsv = unwrap(
	stringify(
		[
			["c", 3],
			["d", null],
		],
		{ delimiter: "\t" },
	),
);
// "c\t3\r\nd\t\r\n"

Pattern: Exchange Data Between Programs

For a file another program reads rather than a spreadsheet, turn formula neutralization off and parse returns exactly the strings stringify wrote, quotes and line breaks included.

import { parse, stringify } from "@sdxc/csv";
import { unwrap } from "@sdxc/result";

let rows = [{ note: 'said "hi", then left\r\nearly', delta: "-5" }];
let text = unwrap(stringify(rows, { escapeFormulas: false }));

unwrap(parse(text)).rows; // [{ note: 'said "hi", then left\r\nearly', delta: "-5" }]

Pattern: Report Errors To The Caller

Both errors locate the problem: line for the reader, row and column for the writer.

import { CSVParseError, CSVStringifyError, parse, stringify } from "@sdxc/csv";
import { isFailure } from "@sdxc/result";

let parsed = parse('a,b\r\n1,"open');
if (isFailure(parsed)) {
	parsed.error instanceof CSVParseError; // true
	parsed.error.line; // 2
}

let written = stringify([{ total: Number.NaN }]);
if (isFailure(written)) {
	written.error instanceof CSVStringifyError; // true
	[written.error.row, written.error.column]; // [0, "total"]
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.