1. The Problem
Build a CSV analyser: read any CSV file, detect column types (number vs string), compute stats for numeric columns (min, max, mean, median, stddev), filter rows by a condition, and export the result. Zero npm dependencies — only Node.js built-ins.
Why this project? Manual CSV parsing, type detection, statistical functions, filter expressions, and stdout/file output — the core toolkit behind every data pipeline.
2. How to Think About This
Parse CSV in two passes: first line is headers, remaining lines are data rows. Split on commas (handle quoted values). Detect column type by checking if every non-empty value parses as a number. Stats computed with reduce. Filter rows by evaluating a JS expression string against each row object using new Function.
3. The Build
import { readFileSync, writeFileSync } from "fs";
// ── Parser ─────────────────────────────────────────────────────────────────
export function parseCSV(text) {
const lines = text.trim().split(/
?
/);
if (lines.length < 2) throw new Error("CSV must have a header row and at least one data row");
const headers = splitCSVLine(lines[0]);
const rows = lines.slice(1).map(line => {
const vals = splitCSVLine(line);
return Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? ""]));
});
return { headers, rows };
}
function splitCSVLine(line) {
const result = [];
let current = "", inQuotes = false;
for (const ch of line) {
if (ch === '"') { inQuotes = !inQuotes; continue; }
if (ch === "," && !inQuotes) { result.push(current.trim()); current = ""; continue; }
current += ch;
}
result.push(current.trim());
return result;
}
// ── Type detection ─────────────────────────────────────────────────────────
export function detectTypes(headers, rows) {
return Object.fromEntries(headers.map(h => {
const vals = rows.map(r => r[h]).filter(v => v !== "");
const isNum = vals.length > 0 && vals.every(v => !isNaN(Number(v)));
return [h, isNum ? "number" : "string"];
}));
}
// ── Statistics ─────────────────────────────────────────────────────────────
export function stats(values) {
const nums = values.map(Number).filter(n => !isNaN(n));
if (!nums.length) return null;
const sum = nums.reduce((a, b) => a + b, 0);
const mean = sum / nums.length;
const sorted = [...nums].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid-1] + sorted[mid]) / 2;
const variance = nums.reduce((a, b) => a + (b - mean) ** 2, 0) / nums.length;
return {
count: nums.length,
min: +Math.min(...nums).toFixed(4),
max: +Math.max(...nums).toFixed(4),
mean: +mean.toFixed(4),
median: +median.toFixed(4),
stddev: +Math.sqrt(variance).toFixed(4),
};
}
// ── Filter ─────────────────────────────────────────────────────────────────
export function filterRows(rows, expression) {
// expression: "age > 30 && city === 'Mumbai'"
// Each row's keys become variables via destructuring
const fn = new Function(...Object.keys(rows[0]), `return ${expression}`);
return rows.filter(row => {
try { return fn(...Object.values(row)); } catch { return false; }
});
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const [,, file, ...flags] = process.argv;
if (!file) {
console.error("Usage: node csv.mjs <file.csv> [--filter 'expr'] [--col colname] [--out out.csv]");
process.exit(1);
}
const text = readFileSync(file, "utf-8");
const { headers, rows } = parseCSV(text);
const types = detectTypes(headers, rows);
const filterIdx = flags.indexOf("--filter");
const colIdx = flags.indexOf("--col");
const outIdx = flags.indexOf("--out");
const filterExpr = filterIdx >= 0 ? flags[filterIdx + 1] : null;
const targetCol = colIdx >= 0 ? flags[colIdx + 1] : null;
const outFile = outIdx >= 0 ? flags[outIdx + 1] : null;
let working = filterExpr ? filterRows(rows, filterExpr) : rows;
console.log(`
File: ${file} (${rows.length} rows, ${headers.length} columns)`);
if (filterExpr) console.log(`Filter "${filterExpr}": ${working.length} rows match`);
const numCols = headers.filter(h => types[h] === "number" && (!targetCol || h === targetCol));
if (numCols.length) {
console.log("
Numeric column statistics:");
numCols.forEach(col => {
const s = stats(working.map(r => r[col]));
if (!s) return;
console.log(`
${col}`);
console.log(` count: ${s.count} min: ${s.min} max: ${s.max}`);
console.log(` mean: ${s.mean} median: ${s.median} stddev: ${s.stddev}`);
});
}
if (outFile) {
const csv = [headers.join(","), ...working.map(r => headers.map(h => r[h]).join(","))].join("
");
writeFileSync(outFile, csv);
console.log(`
Exported ${working.length} rows → ${outFile}`);
}
4. Test & Prove
import { parseCSV, detectTypes, stats, filterRows } from "./csv.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
const CSV = "name,age,city
Alice,30,Mumbai
Bob,25,Delhi
Carol,35,Mumbai";
const { headers, rows } = parseCSV(CSV);
test("headers parsed", () => { if(headers.join()!=="name,age,city") throw new Error(); });
test("row count", () => { if(rows.length!==3) throw new Error(rows.length); });
test("age detected as number", () => { if(detectTypes(headers,rows).age!=="number") throw new Error(); });
test("name detected as string", () => { if(detectTypes(headers,rows).name!=="string") throw new Error(); });
test("stats mean", () => { const s=stats(rows.map(r=>r.age)); if(Math.abs(s.mean-30)>0.01) throw new Error(s.mean); });
test("stats min/max", () => { const s=stats(rows.map(r=>r.age)); if(s.min!==25||s.max!==35) throw new Error(); });
test("filter works", () => { const f=filterRows(rows,"age > 28"); if(f.length!==2) throw new Error(f.length); });
test("filter no match", () => { const f=filterRows(rows,"age > 100"); if(f.length!==0) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);5. The Interface
node csv.mjs data.csv node csv.mjs data.csv --filter "age > 30" node csv.mjs data.csv --col salary --filter "dept === 'Engineering'" node csv.mjs data.csv --filter "city === 'Mumbai'" --out mumbai.csv
6. Run It
# Create a test file:
echo "name,age,score
Alice,30,95
Bob,25,87
Carol,35,72" > test.csv
node csv.mjs test.csv
node csv.test.mjs🎯 Try this next
- Add a
--sort colflag to sort rows by a column value - Add
--format jsonto output results as JSON instead of text - Add a frequency count for string columns (most common values)
- Handle multi-line quoted fields correctly (CSV fields can contain newlines)
What you practised
Manual CSV parsing with quoted-field support, type detection via Array.every(), statistical reduce patterns (mean/median/stddev), dynamic row filtering with new Function, and piping data through a CLI with multiple flags.
Reference: Arrays · Regular Expressions · Destructuring & Spread