sdxc

Type to search, or start from one of these:

[ 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

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

parse reads text into records keyed by the header row, stringify writes rows back as one string, and streamify writes them as a UTF-8 byte stream while they arrive. Each is its own named export, so a module that only reads leaves the writers out of the bundle, and a namespace import reads CSV.parse(...) the way JSON.parse does. Where JSON throws, every function here returns a Result.

The reader returns strings only: CSV has no numbers, dates or nulls, and a guessed type can lose data ("007" read as 7), so rows are typed by your own schema. The writer takes typed cells, quotes exactly what RFC 4180 requires, and by default neutralizes string cells a spreadsheet would run as a formula.

Installation

npm add @sdxc/csv

Results come from @sdxc/result, which is where unwrap and isFailure come from. It installs alongside this package.

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 }); // "... at line 12"
}

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

Write For Excel

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

let result = stringify(rows, {
	columns: [
		{ key: "date", header: "Fecha (UTC)" },
		{ key: "uptime", header: "Disponibilidad %" },
	],
	delimiter: ";",
	bom: true,
});

Excel in decimal-comma locales (Spanish, German, French, Italian) splits a .csv on ;, and it reads the file as UTF-8 only when it starts with a byte order mark.

Stream A Download

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

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

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

API

parse(source, options?)

Reads CSV text and returns Result<Parsed<Record<string, string>>, CSVParseError>, or Parsed<string[]> with header: false.

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

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

Records may end in CRLF, LF or a lone CR. A leading BOM is dropped and blank lines are skipped. 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. A bare " inside an unquoted field is kept and reported in warnings.

stringify(rows, options?)

Writes objects under a header record, or arrays of cells as bare records, and returns Result<string, CSVStringifyError>. 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 (default false)

  • escapeFormulas: prefix ' to string cells starting with =, +, -, @, tab or CR (default true)

A cell is string | number | bigint | boolean | Date | null | undefined. null and undefined write an empty field, a Date writes ISO 8601 in UTC, and numbers write String(value). Rows typed by an interface are accepted as they are. CSVStringifyError names the row and column of a value outside those types, an invalid Date, or a number with no finite value, and has no row when the delimiter is unknown.

streamify(rows, options)

Takes the same options as stringify, with columns required, over an Iterable or AsyncIterable of objects, and returns 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, and cancelling the stream calls the source iterator's return(). A bad cell errors the stream with a CSVStringifyError.

Types

Cell, CellRecord, Column, Delimiter, ParseOptions, Parsed, ParseWarning, StringifyOptions and WriteOptions are exported at the top level, so a namespace import reads CSV.Cell.

Pattern: Type Uploaded Rows

Every field arrives as a string, so a check per row turns records into the values your code works with and rejects the ones that are not.

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

interface Monitor {
	name: string;
	url: URL;
	intervalSeconds: number;
}

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

let monitors: Monitor[] = [];
let problems: string[] = [];

for (let [index, row] of parsed.data.rows.entries()) {
	let intervalSeconds = Number(row.interval_seconds);
	if (!row.name || !URL.canParse(row.url) || !Number.isInteger(intervalSeconds)) {
		problems.push(`Row ${index + 2} is incomplete`); // + 1 for the header, + 1 for 1-based
		continue;
	}
	monitors.push({ name: row.name, url: new URL(row.url), intervalSeconds });
}

Pattern: Export Rows From A Database Cursor

streamify reads an async iterable, so an export can stream straight from a paged query without holding every row 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" },
	],
});

Pattern: Read Back What Was Written

parse(unwrap(stringify(rows))) returns the rows as strings for every delimiter. Fields holding the delimiter, quotes, line breaks or edge spaces are quoted, and a record of one empty field is written as "" so it reads back as that field instead of a skipped blank line.

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

let rows = [{ note: 'said "hi", then left\r\nearly' }];
let text = unwrap(stringify(rows, { delimiter: "\t" }));

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