A CSV validator that never leaves the browser
The second thing in /lab is a CSV validator: paste a JSON Schema, drop in a CSV, and get back a valid/invalid count plus a row-by-row error table — entirely client-side. Nothing is uploaded anywhere, which for a tool whose whole point is “check my data” felt like a hard requirement, not a nice-to-have.
Interpreting JSON Schema instead of compiling it
The obvious way to turn a JSON Schema into a Zod schema is a codegen library — something that turns the schema into a string of Zod calls and runs it. That’s a bad idea the moment the schema comes from a text box: it means eval-ing (or new Function-ing) arbitrary pasted input.
src/lib/jsonSchemaToZod.ts instead walks the schema as plain data and builds Zod validators directly by calling the Zod API itself — z.string().min(...), z.string().refine(...), and so on. The pasted JSON never becomes code; it only ever selects which pre-written builder function runs. That also bounds the scope on purpose: only type, enum, minLength/maxLength, minimum/maximum, pattern, and a handful of format values are supported. Full JSON Schema has nested objects, $ref, allOf/oneOf — none of which map onto a flat CSV row anyway, so there was no reason to support them.
Every CSV cell is a string, and that has teeth
PapaParse hands back every cell as a string, even for a column the schema calls number or boolean. The naive fix, z.coerce.number(), has a sharp edge: Number('') is 0, so an empty cell in a required numeric column silently coerces to a valid 0 instead of failing as missing. z.coerce.boolean() has a worse one — it’s just Boolean(value), so the string "false" coerces to true, because a non-empty string is always truthy.
Both get handled the same way: every row is pre-processed to delete any key whose raw value is an empty string, before it ever reaches Zod. An empty cell becomes a genuinely absent key, so a required field fails Zod’s normal “missing” check instead of quietly coercing to a falsy-looking value. Booleans get their own small parser ("true"/"1"/"yes" vs "false"/"0"/"no", case-insensitive, anything else is a validation error) rather than trusting Boolean() at all.
Streaming from a File, not a pasted blob
The CSV goes in via a file input (with drag-and-drop), not a paste-into-a-textarea box. That’s what lets Papa.parse(file, { step, header: true }) stream rows from disk via FileReader instead of ever holding the whole file as one string in memory — the difference between a validator that scales to a large export and one that doesn’t.
Validation runs inside that same step callback, one row at a time, so nothing is fully buffered until the end except the two output arrays (passing rows and failing rows) needed for export.
Rendering untrusted values safely
Every value shown in the error table — and every column name in the summary, since those come straight from the CSV’s header row — is attacker-controlled input as far as the DOM is concerned. All of it goes in through textContent, never innerHTML, so a CSV cell containing something that looks like a <script> tag just renders as inert text instead of running.
A bug the tests caught: hidden losing to display: flex
Testing the “run it twice” path surfaced a real bug: the results and error panels are toggled with the plain hidden attribute, but their CSS also sets display: flex. Author styles and the browser’s built-in [hidden] { display: none } rule have equal specificity, so whichever rule comes later in the stylesheet wins — and an author rule always loads after the user-agent stylesheet. The result: setting .hidden = true in JS did nothing visually, so stale results from a previous run stayed on screen while a new file was still being validated. The fix is a one-line .cv-results[hidden] { display: none; } override — a good reminder that hidden and a display declaration on the same element need to be told which one wins.
Ideas for later — and what “becoming a tool” would need
The top nav already has a tools entry, greyed out with a “Coming soon” tooltip — the intended home for anything in /lab that’s outgrown “small experiment” and become something worth relying on. This validator is the most likely first candidate. Getting it there would mean:
- A real JSON Schema subset, properly labelled. Document the supported keyword list on the page itself (not just in this note), and consider
$ref-free nested objects for CSVs with dotted or bracketed column names (address.city), which the current flat-shape assumption can’t express. - Bigger-file confidence.
worker: truewas skipped to dodge PapaParse-in-a-bundler friction, but a real “large export” use case would want it — or at minimum a visible row-count/size warning before the browser’s tab starts to strain. - Saved schemas. Pasting the same schema in every time is fine for a demo; a real tool would let you name and keep a handful of schemas in
localStorageand pick from a dropdown. - Richer formats.
format: "date"currently just checksDate.parsedoesn’t returnNaN, which is looser than the JSON Schema spec intends (it’ll happily accept"9/6/2026"). Worth tightening once real users hit it. - A second export shape: the error table itself as a downloadable CSV (row/column/issue/value), not just the passing/failing source rows, for pasting into a bug report or a data-quality ticket.
None of that is needed for a lab experiment. All of it is exactly the gap between “cool demo” and “thing a colleague could actually use.”