schema - one declaration, two outputs

truecopy/schema

Write the schema once and get the check and the record type. Or bring the one you already wrote in Zod, Valibot or ArkType.

A reader written naturally is opportunistic: it looks for signals (a year, a number, an amount) and takes what it finds. The only refusal it can put into words is “I found nothing”, so it accepts anything that happens to carry the shape of what it seeks.

Measured before this existed: a meeting report mentioning 2019 and four sites came back as a career record, six findings, one of them at the highest severity.

The schema reverses the burden of proof.

import { schemaOf, validate, type RecordOf } from 'truecopy/schema';

export const CAREER = schemaOf({
  name: 'career-record',
  fields: {
    year: { format: 'year', minimum: 1930, maximum: 2030, required: true },
    quarters: { format: 'integer', minimum: 0, maximum: 4, required: false },
    pay: { format: 'number', minimum: 0, required: false }
  },
  minimumRecords: 5,
  key: 'year'
});

export type CareerRow = RecordOf<typeof CAREER>;
//   { year: number; quarters: number | null; pay: number | null }

One declaration, two outputs. A shape declared twice drifts, and the half that drifts silently is always the check.

const violation = validate(CAREER, rows);
// null
// { cause: 'too-few-records', conforming: 1, expected: 5 }
// { cause: 'out-of-format', field: 'year', value: 2199, record: … }

What is missing is named, never guessed: this library says which rule broke, your application writes the sentence in its own voice.

Format before count

An absurd value says the document was cut up wrongly, and counting the records of a wrong cut tells you nothing. So validate reports the format failure first, and reports one rule, because a screen listing three failures is a screen nobody reads.

The key, and what it separates

key counts distinct values rather than records, and it is what tells a table from a document about one single thing.

A payslip carries the vocabulary of retirement and repeats one year; a career record lines up forty. Counting records does not tell them apart. Counting years does.

A schema is pure data

No accessor, no closure, nothing that does not survive JSON. It can be served by a backend, versioned, refined by whoever reads the documents, and swapped for another market without a deploy.

The cost is one rule: a record carries each value under the field’s own name. A reader whose rows are shaped otherwise flattens them in its own code, where the mapping stays visible.

Bring your own schema

Already declared that row type in Zod, Valibot, ArkType or TypeBox? Do not declare it twice.

import { validateWith } from 'truecopy/schema';

validateWith(myZodSchema, rows, { minimumRecords: 5, key: 'year' });

Any Standard Schema works. What truecopy adds is the part a per-record validator cannot know: how many well-formed records make a document.

Synchronous only, and it says so rather than quietly awaiting: a reading runs inside a render path, and turning it async would spread through every caller for the sake of validators nobody writes that way.

This is not the kind question

Whether the document is the one you expect at all belongs to classify, which asks it with precedence rules. Two answers to one question always end up disagreeing.