ProvenPin Install

CSV import with PapaParse + Zod row validation: working versions (tested 2026-10-15)

Parse CSV uploads with PapaParse (headers, BOM, quoted fields), validate each row with Zod and report row-numbered errors instead of failing the whole file.

passed3 passed · 0 failed2026-10-15 13:32 UTC
Status
passed
Last passed
2026-10-15 13:32 UTC
Tests
3 / 3
Language
TypeScript
Runtime
node v22.23.3
pinned versions5
papaparse@5.7.0zod@4.6.7tsx@4.23.15@types/node@24.19.2@types/papaparse@5.5.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

The code

Exactly the files that passed. Your agent can pull them with get_recipe("papaparse-zod-csv-import") (install), or see them on GitHub.

package.json
{
  "name": "papaparse-zod-csv-import",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "papaparse": "5.7.0",
    "zod": "4.6.7"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "24.19.2",
    "@types/papaparse": "5.5.2"
  }
}
src/import.ts
import Papa from "papaparse";
import { z } from "zod";

const Row = z.object({
  email: z.email(),
  name: z.string().min(1),
  seats: z.coerce.number().int().min(1),
  active: z.enum(["yes", "no"]).transform((v) => v === "yes"),
});
export type Customer = z.infer<typeof Row>;

export function importCustomers(csv: string) {
  const parsed = Papa.parse<Record<string, string>>(csv.replace(/^\uFEFF/, ""), {
    header: true,
    skipEmptyLines: "greedy",
    transformHeader: (h) => h.trim().toLowerCase(),
    transform: (v) => v.trim(),
  });
  const ok: Customer[] = [];
  const errors: { row: number; message: string }[] = parsed.errors.map((e) => ({ row: (e.row ?? 0) + 2, message: e.message }));
  parsed.data.forEach((raw, i) => {
    const r = Row.safeParse(raw);
    if (r.success) ok.push(r.data);
    else errors.push({ row: i + 2, message: r.error.issues.map((x) => `${x.path.join(".")}: ${x.message}`).join("; ") }); // +2: header + 1-based
  });
  return { ok, errors };
}

export const exportCustomers = (rows: Customer[]) => Papa.unparse(rows.map((r) => ({ ...r, active: r.active ? "yes" : "no" })), { quotes: true });
test/import.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { exportCustomers, importCustomers } from "../src/import.ts";

const csv = "\uFEFFEmail , Name,Seats,Active\[email protected],\"Lovelace, Ada\",3,yes\nbad,Bo,0,maybe\n\[email protected],Cy,10,no\n";

test("valid rows parsed, quoted commas kept, BOM and blank lines ignored", () => {
  assert.deepEqual(importCustomers(csv).ok, [
    { email: "[email protected]", name: "Lovelace, Ada", seats: 3, active: true },
    { email: "[email protected]", name: "Cy", seats: 10, active: false },
  ]);
});

test("errors carry spreadsheet row numbers", () => {
  const { errors } = importCustomers(csv);
  assert.equal(errors.length, 1);
  assert.equal(errors[0].row, 3);
  assert.match(errors[0].message, /email/);
  assert.match(errors[0].message, /seats/);
});

test("round-trips through export", () => {
  const { ok } = importCustomers(csv);
  assert.deepEqual(importCustomers(exportCustomers(ok)).ok, ok);
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.