Foundations

Schemas of schemas

Describing data that describes data: a schema held as a value, so the interface that edits records edits their shape.

Markdown

A schema is data, so it has a shape of its own: a kind, and the settings that kind takes. Describe that shape as a schema, and whatever builds a form from a schema builds the form that edits schemas. People can then make and change the shape of their own records — add a field, rename one, give a number a limit — with the controls they already use for the records themselves.

A schema, edited

On one side, a product's schema as data: its fields in a table, and the one whose row is active with every setting of its kind. On the other, that schema put to use: a form and a grid of records. Rename price to cost, make status a text, add a field, give two fields one key, set a least above a most: what is wrong is marked in the table where it is, and the records keep what still fits.

Fields

Select all
Key
Kind
Label
Optional
Formula
1
Text
Name
2
price
Number
Price
3
taxed
Number
With tax
round(price * 1.1, 2)
4
status
Choice
Status
5
released
Date
Released
6
tags
List
Tag
7
dimensions
Record
Dimensions
+

The form and the records follow every change.

Field

Field

The name its value is held under.

What people call this value.

How it is computed from the record's other fields, as price * quantity.

In characters as people count them.

In characters as people count them.

A regular expression the whole text must match.

What the text is, for its keyboard and for filling it in.

A sentence of help: what the value is for, or the form it takes.

May be left without a value.

Shown, but not for changing.

A record

Computed as round(price * 1.1, 2).

Tag

Lowercase letters, digits and hyphens.

Dimensions

Records

Select all
Name
Price
With tax
Status
Released
1
$48.00
$52.80
Live
Mar 2, 2026
2
Tea caddy
$22.50
$24.75
In review
3
Tray
$36.00
$39.60
Draft
+

3 records. A list or a nested record has no column: it is edited in the form. With tax follows the price; type over it to freeze it, or hold a cell from the menu, and a change to the schema that would move it is refused.

Code
"use client";

import { DataGrid } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import type { CellChange, Run } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import { ObjectEditor } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import type { ObjectEditorHandle } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import { Select, SelectItem } from "@publictheta/ui/components/select/select.tsx";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import {
  checkSchema,
  fieldSchema,
  fromSchema,
  schemaSchema,
  toSchema,
} from "@publictheta/ui/primitives/schema-schema.ts";
import {
  blank,
  conform,
  conforms,
  schema,
  tagSchema,
  validate,
  withValueAt,
  withVariant,
} from "@publictheta/ui/primitives/schema.ts";
import type { Issue, ObjectSchema } from "@publictheta/ui/primitives/schema.ts";
import {
  conformRecords,
  deriveNested,
  deriveRecord,
  derivationsOf,
  lockKey,
} from "@publictheta/ui/primitives/derived.ts";
import type { Lock } from "@publictheta/ui/primitives/derived.ts";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";

const product = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", label: "Name", maxLength: 40 } },
    {
      key: "price",
      schema: { kind: "number", label: "Price", minimum: 0, format: "currency", currency: "USD" },
    },
    {
      key: "taxed",
      schema: {
        kind: "number",
        label: "With tax",
        format: "currency",
        currency: "USD",
        formula: "round(price * 1.1, 2)",
      },
    },
    {
      key: "status",
      schema: {
        kind: "choice",
        label: "Status",
        default: "draft",
        options: [
          { value: "draft", label: "Draft" },
          { value: "review", label: "In review" },
          { value: "live", label: "Live" },
        ],
      },
    },
    { key: "released", schema: { kind: "date", label: "Released", optional: true } },
    {
      key: "tags",
      schema: {
        kind: "list",
        label: "Tag",
        description: "Lowercase letters, digits and hyphens.",
        maxItems: 3,
        item: { kind: "text", pattern: "[a-z0-9-]+" },
      },
    },
    {
      key: "dimensions",
      schema: {
        kind: "object",
        label: "Dimensions",
        fields: [
          {
            key: "width",
            schema: { kind: "number", label: "Width", minimum: 0, dimension: "length", unit: "mm" },
          },
          {
            key: "height",
            schema: { kind: "number", label: "Height", minimum: 0, dimension: "length", unit: "mm" },
          },
        ],
      },
    },
  ],
});

type Row = Readonly<Record<string, unknown>>;

const PRODUCTS: readonly Row[] = [
  {
    name: "Teapot",
    price: 48,
    status: "live",
    released: "2026-03-02",
    tags: ["kitchen", "stoneware"],
    dimensions: { width: 180, height: 120 },
  },
  {
    name: "Tea caddy",
    price: 22.5,
    status: "review",
    released: null,
    tags: ["kitchen"],
    dimensions: { width: 80, height: 110 },
  },
  {
    name: "Tray",
    price: 36,
    status: "draft",
    released: null,
    tags: [],
    dimensions: { width: 320, height: 20 },
  },
];

/** One field in a record's description, whole or not: its `key`, and its `schema`'s description. */
type Field = Readonly<Record<string, unknown>>;

/** The description of the record's schema as it is being edited, whole or not. */
interface Description {
  readonly kind: "object";
  readonly fields: readonly Field[];
  readonly [setting: string]: unknown;
}

/** A field as the table of fields shows it: the settings worth a column of their own. */
const fieldRow = schema({
  kind: "object",
  fields: [
    { key: "key", schema: { kind: "text", label: "Key" } },
    // The kinds a schema can be, as the schema of schemas lists them.
    { key: "kind", schema: tagSchema(schemaSchema) },
    { key: "label", schema: { kind: "text", label: "Label", optional: true } },
    { key: "optional", schema: { kind: "boolean", label: "Optional" } },
    { key: "formula", schema: { kind: "text", label: "Formula", optional: true } },
  ],
});

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

// The same row for the same field, so the table sees a change only where there was one.
const rows = new WeakMap<Field, Row>();

function rowOf(field: Field): Row {
  let row = rows.get(field);
  if (row === undefined) {
    const of = isRecord(field.schema) ? field.schema : {};
    row = {
      key: field.key,
      kind: of.kind ?? null,
      label: of.label ?? null,
      optional: of.optional === true,
      formula: of.formula ?? null,
    };
    rows.set(field, row);
  }
  return row;
}

/** A field with one cell of its row changed. Another kind keeps the settings the two kinds share. */
function withCell(field: Field, key: string, value: unknown): Field {
  if (key === "key") return { ...field, key: value };
  if (key === "kind") {
    return {
      ...field,
      schema: withVariant(schemaSchema, field.schema, typeof value === "string" ? value : null),
    };
  }
  if (!isRecord(field.schema)) return field;
  return { ...field, schema: { ...field.schema, [key]: key === "optional" ? value === true : value } };
}

/** The field a new row of the table stands for. */
function fieldOf(row: Row): Field {
  let field: Field = blank(fieldSchema);
  for (const key of ["kind", "key", "label", "optional", "formula"]) {
    if (row[key] !== undefined && row[key] !== null) field = withCell(field, key, row[key]);
  }
  return field;
}

/**
 * What is wrong with a description: what the schema of schemas says of it, and
 * once that is nothing, what it cannot say — a minimum above its maximum, a key
 * used twice.
 */
function problemsOf(description: Description): Issue[] {
  if (!conforms(schemaSchema, description)) return validate(schemaSchema, description);
  return checkSchema(toSchema(description));
}

interface State {
  /** The schema as it is being edited, whole or not. */
  readonly description: Description;
  /** The last description with nothing wrong, as a schema: what the form and the records are built from. */
  readonly schema: ObjectSchema;
  readonly records: readonly Row[];
  /** The record in the form. */
  readonly draft: unknown;
  /** The locks on the form's fields. */
  readonly locks: ReadonlyMap<string, Lock>;
  /** The locks on the records' cells, each under the record's place and the field's key. */
  readonly recordLocks: ReadonlyMap<string, Lock>;
  /** Why the description as it stands is not the schema, though nothing is wrong with it. */
  readonly refused: string | null;
}

/** A record with every value it computes made right, keeping what is frozen. */
function computed(definition: ObjectSchema, record: unknown, frozen?: (key: string) => boolean): Row {
  const fitted = isRecord(record) ? record : {};
  const whole = deriveNested(definition, undefined, fitted);
  return deriveRecord(derivationsOf(definition).plan, whole, frozen === undefined ? {} : { frozen });
}

/** The same record when it already computes as it should, so the table sees a change only where there was one. */
function recomputed(definition: ObjectSchema, record: Row, frozen?: (key: string) => boolean): Row {
  const next = computed(definition, record, frozen);
  return Object.keys(next).every((key) => Object.is(next[key], record[key])) ? record : next;
}

/**
 * The held values a schema change would take or move — in the form's record, and
 * in the records — by name, with the values; `null` when there are none.
 */
function heldTaken(state: State, next: ObjectSchema, draft: Row, records: readonly Row[]): string | null {
  const named: string[] = [];
  const formLock = (key: string) => state.locks.get(key);
  const taken = conformRecords(next, state.schema, [state.draft as Row], (_row, key) =>
    formLock(key),
  ).blocked.map((cell) => cell.key);
  // A formula changed under a held value would move it as surely.
  for (const key of state.locks.keys()) {
    if (formLock(key) === "held" && !taken.includes(key) && Object.hasOwn(draft, key)) {
      if (!Object.is(draft[key], (state.draft as Row)[key])) taken.push(key);
    }
  }
  for (const key of taken)
    named.push(`${key} (${JSON.stringify((state.draft as Row)[key] ?? null)}) in the form`);
  const recordLock = (row: number, key: string) => state.recordLocks.get(lockKey(String(row), key));
  const blocked = conformRecords(next, state.schema, state.records, recordLock).blocked;
  const moved = new Set(blocked.map((cell) => `${cell.row} ${cell.key}`));
  for (const [row, record] of state.records.entries()) {
    for (const key of Object.keys(record)) {
      const now = records[row];
      if (recordLock(row, key) === "held" && now !== undefined && Object.hasOwn(now, key)) {
        if (!Object.is(now[key], record[key])) moved.add(`${row} ${key}`);
      }
    }
  }
  for (const at of moved) {
    const [row = "0", key = ""] = at.split(" ");
    const value = state.records[Number(row)]?.[key] ?? null;
    named.push(`${key} (${JSON.stringify(value)}) in record ${Number(row) + 1}`);
  }
  if (named.length === 0) return null;
  return `Not applied: it would change ${named.length === 1 ? "a held value" : "held values"} — ${named.join(", ")}. Unlock ${named.length === 1 ? "it" : "them"} first.`;
}

/** The records' locks kept under the names their fields now have. */
function renamedRecordLocks(
  locks: ReadonlyMap<string, Lock>,
  before: ObjectSchema,
  after: ObjectSchema,
): ReadonlyMap<string, Lock> {
  if (locks.size === 0) return locks;
  const byRecord = new Map<string, Map<string, Lock>>();
  for (const [key, lock] of locks) {
    const [row = "", field = ""] = key.split("\u001f");
    const fields = byRecord.get(row) ?? new Map<string, Lock>();
    fields.set(field, lock);
    byRecord.set(row, fields);
  }
  const next = new Map<string, Lock>();
  for (const [row, fields] of byRecord) {
    for (const [field, lock] of renamedLocks(fields, before, after)) next.set(lockKey(row, field), lock);
  }
  return next;
}

/** Locks kept under the names their fields now have. */
function renamedLocks(
  locks: ReadonlyMap<string, Lock>,
  before: ObjectSchema,
  after: ObjectSchema,
): ReadonlyMap<string, Lock> {
  if (locks.size === 0) return locks;
  const next = new Map<string, Lock>();
  const now = new Set(after.fields.map((field) => field.key));
  const was = new Set(before.fields.map((field) => field.key));
  for (const [key, lock] of locks) {
    if (now.has(key)) next.set(key, lock);
    else {
      // Renamed in place: the field now where it stood has a name it did not have before.
      const at = before.fields.findIndex((field) => field.key === key);
      const there = after.fields[at]?.key;
      if (there !== undefined && !was.has(there)) next.set(there, lock);
    }
  }
  return next;
}

/**
 * The state after an edit of the description. A whole description becomes the
 * schema, and every record is made to fit it: what still fits is kept, a
 * renamed field keeps its value, the rest starts blank, and what is computed
 * is computed. A change that would take a held value from the form is not made.
 */
function described(state: State, description: Description): State {
  if (!conforms(schemaSchema, description) || problemsOf(description).length > 0) {
    return { ...state, description, refused: null };
  }
  const next = toSchema(description) as ObjectSchema;
  const locks = renamedLocks(state.locks, state.schema, next);
  const draft = computed(
    next,
    conform(next, state.draft, state.schema),
    (key) => locks.get(key) === "frozen",
  );
  const recordLocks = renamedRecordLocks(state.recordLocks, state.schema, next);
  // What each record computes is computed again, but for what is frozen in it.
  const records = state.records.map((record, row) =>
    recomputed(
      next,
      conform(next, record, state.schema),
      (key) => recordLocks.get(lockKey(String(row), key)) === "frozen",
    ),
  );
  const refused = heldTaken(state, next, draft, records);
  if (refused !== null) return { ...state, description, refused };
  return {
    description,
    schema: next,
    records: records.every((record, index) => record === state.records[index]) ? state.records : records,
    draft,
    locks,
    recordLocks,
    refused: null,
  };
}

function Section({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section className="flex min-w-0 flex-col gap-3">
      <h4 className="text-sm font-semibold text-fg-strong">{title}</h4>
      {children}
    </section>
  );
}

export default function SchemaEditor() {
  const [state, setState] = useState<State>(() => ({
    description: fromSchema(product) as Description & { readonly kind: "object" },
    schema: product,
    records: PRODUCTS.map((row) => computed(product, row)),
    draft: computed(product, blank(product)),
    locks: new Map(),
    recordLocks: new Map(),
    refused: null,
  }));
  const { description, records, draft } = state;
  const { fields } = description;
  const change = (next: Description) => setState((current) => described(current, next));
  const setFields = (next: readonly Field[]) => change({ ...description, fields: next });

  // Which field the inspector shows.
  const [picked, setPicked] = useState(0);
  const at = Math.min(picked, fields.length - 1);
  const field = fields[at];
  const fieldLabelId = useId();
  const name = (index: number) => {
    const key = fields[index]?.key;
    return typeof key === "string" && key !== "" ? key : `Field ${index + 1}`;
  };
  function move(by: 1 | -1) {
    const to = at + by;
    if (field === undefined || to < 0 || to >= fields.length) return;
    setFields(fields.toSpliced(at, 1).toSpliced(to, 0, field));
    setPicked(to);
  }

  const problems = useMemo(() => problemsOf(description), [description]);
  // Those of the field in the inspector, as paths within it.
  const fieldProblems = useMemo(
    () =>
      problems
        .filter((issue) => issue.path[0] === "fields" && issue.path[1] === at)
        .map((issue) => ({ ...issue, path: issue.path.slice(2) })),
    [problems, at],
  );
  // Those the table has a cell for, each by its field's place and the column it is in.
  const cellProblems = useMemo(
    () =>
      problems.flatMap((issue): Issue[] => {
        const [list, index, part, setting] = issue.path;
        if (list !== "fields" || typeof index !== "number") return [];
        const column = part === "schema" ? setting : part;
        return typeof column === "string" ? [{ ...issue, path: [index, column] }] : [];
      }),
    [problems],
  );
  const inspector = useRef<ObjectEditorHandle>(null);
  const [shown, setShown] = useState(false);
  // How many times the first problem has been asked for: each time, focus goes to it.
  const [sought, setSought] = useState(0);
  useEffect(() => {
    if (sought > 0) inspector.current?.focusFirstIssue();
  }, [sought]);
  const fieldRows = useMemo(() => fields.map(rowOf), [fields]);

  const form = useRef<ObjectEditorHandle>(null);
  const [attempted, setAttempted] = useState(false);
  const formatter = new Intl.NumberFormat("en-US");

  return (
    <div className="@container w-full">
      <div className="grid gap-x-6 gap-y-10 @2xl:grid-cols-2">
        <div className="flex min-w-0 flex-col gap-6">
          <Section title="Fields">
            <DataGrid
              aria-label="Fields"
              schema={fieldRow}
              locale="en-US"
              rows={fieldRows}
              onCellsChange={(changes: readonly CellChange[]) => {
                const next = fields.slice();
                for (const { row, key, value } of changes) {
                  if (next[row] !== undefined) next[row] = withCell(next[row], key, value);
                }
                setFields(next);
              }}
              onRowsInsert={(index, added) => {
                setFields(fields.toSpliced(index, 0, ...added.map(fieldOf)));
                setPicked(index);
              }}
              onRowsRemove={(runs: ReadonlyArray<Run<Row>>) => {
                const gone = new Set(runs.flatMap((run) => run.rows.map((_, index) => run.at + index)));
                setFields(fields.filter((_, index) => !gone.has(index)));
              }}
              issues={cellProblems}
              // The inspector shows the field whose row the active cell is in.
              onActiveCellChange={(cell) => {
                if (cell !== null && cell.row < fields.length) setPicked(cell.row);
              }}
              columnWidth={116}
              style={{ height: 264 }}
            />
            <p aria-live="polite" className="flex min-h-8 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
              {problems.length === 0 && state.refused !== null ? (
                <span className="text-danger">{state.refused}</span>
              ) : problems.length === 0 ? (
                <span className="text-fg-muted">The form and the records follow every change.</span>
              ) : (
                <>
                  <span className="text-danger">
                    {formatter.format(problems.length)} {problems.length === 1 ? "problem" : "problems"}: the
                    form and the records keep the last schema that had none.
                  </span>
                  <Button
                    onClick={() => {
                      const first = problems.find((issue) => issue.path[0] === "fields");
                      if (typeof first?.path[1] === "number") setPicked(first.path[1]);
                      setShown(true);
                      setSought(sought + 1);
                    }}
                  >
                    Show the first
                  </Button>
                </>
              )}
            </p>
          </Section>

          <Section title="Field">
            {field === undefined ? (
              <p className="text-sm text-fg-muted">Type a key into the table above to add a field.</p>
            ) : (
              <>
                <div className="flex flex-wrap items-center gap-2">
                  <span id={fieldLabelId} className="sr-only">
                    Field
                  </span>
                  <Select
                    aria-labelledby={fieldLabelId}
                    className="min-w-0 flex-1"
                    value={String(at)}
                    onValueChange={(next) => setPicked(Number(next))}
                  >
                    {fields.map((_, index) => (
                      // Fields have no identity but their place: a key may be missing, or used twice.
                      // oxlint-disable-next-line react/no-array-index-key
                      <SelectItem key={index} value={String(index)}>
                        {name(index)}
                      </SelectItem>
                    ))}
                  </Select>
                  <Button disabled={at === 0} onClick={() => move(-1)}>
                    Move up
                  </Button>
                  <Button disabled={at === fields.length - 1} onClick={() => move(1)}>
                    Move down
                  </Button>
                </div>
                <ObjectEditor
                  ref={inspector}
                  aria-label={name(at)}
                  schema={fieldSchema}
                  locale="en-US"
                  value={field}
                  onValueChange={(next) => change(withValueAt(description, ["fields", at], next))}
                  issues={fieldProblems}
                  showAllIssues={shown}
                />
              </>
            )}
          </Section>
        </div>

        <div className="flex min-w-0 flex-col gap-6">
          <Section title="A record">
            <form
              className="flex flex-col gap-4"
              onSubmit={(event) => {
                event.preventDefault();
                if (validate(state.schema, draft).length > 0) {
                  setAttempted(true);
                  form.current?.focusFirstIssue();
                  return;
                }
                setAttempted(false);
                // The record goes into the table with its locks.
                const recordLocks = new Map(state.recordLocks);
                for (const [key, lock] of state.locks)
                  recordLocks.set(lockKey(String(records.length), key), lock);
                setState({
                  ...state,
                  records: [...records, draft as Row],
                  draft: computed(state.schema, blank(state.schema)),
                  locks: new Map(),
                  recordLocks,
                });
              }}
            >
              <ObjectEditor
                ref={form}
                schema={state.schema}
                locale="en-US"
                value={draft}
                onValueChange={(next) => setState((current) => ({ ...current, draft: next }))}
                locks={state.locks}
                onLocksChange={(locks) => setState((current) => ({ ...current, locks }))}
                showAllIssues={attempted}
              />
              <Button type="submit" className="self-start">
                Add to the records
              </Button>
            </form>
          </Section>

          <Section title="Records">
            <DataGrid
              aria-label="Records"
              schema={state.schema}
              locale="en-US"
              rows={records}
              // What a row computes comes with what was typed into it, from the table itself.
              onRowsChange={(next) => setState((current) => ({ ...current, records: next }))}
              locks={state.recordLocks}
              onLocksChange={(recordLocks) => setState((current) => ({ ...current, recordLocks }))}
              columnWidth={132}
              style={{ height: 220 }}
            />
            <p className="text-sm text-fg-muted">
              {formatter.format(records.length)} {records.length === 1 ? "record" : "records"}. A list or a
              nested record has no column: it is edited in the form. With tax follows the price; type over it
              to freeze it, or hold a cell from the menu, and a change to the schema that would move it is
              refused.
            </p>
          </Section>
        </div>
      </div>
    </div>
  );
}

Best practices

Describe schemas with the schema they are written in. One description, of which every kind is a case: its kind, then what only that kind has, then what every kind shares. Nothing about the editor of schemas is then special; it is the editor of records, given a different record.

Give each setting its own kind. A number's least and most are numbers, a date's are dates, a text's pattern is text, whether it runs to several lines is a yes-or-no, what it holds is a choice, a choice's options are a list of values with labels. People set a limit with the control that reads that kind, and a limit of the wrong kind cannot be entered.

Let a description contain descriptions. A record's fields and a list's items are schemas too. The description refers to itself there, and is followed only as far as the value in hand goes: a description of any depth is edited, and none is drawn before it exists.

Show a kind's settings when the kind is chosen. Choosing among the kinds comes first, and the settings of the chosen one follow it. Changing the kind keeps what the two have in common — the label, the help, whether it is optional — and starts the rest afresh.

Keep a description that is being edited apart from the schema in use. While someone is half-way through a change the description may be incomplete — a field with no key yet. What is built from the schema keeps the last description that was whole, and says that it does; it takes up the new one as soon as that is whole.

Check what a description cannot state of itself. That a least is not above its most, that a starting value is one its own limits allow, that a pattern can be read, that no two fields of a record share a key: these are relations between settings, not limits on one. Check them beside the description, and report each at the setting to change, in the same way as any other problem. A single value's formula is one of its settings, read against the other fields of its record as it is written: a name that is no field, a kind that does not fit, a field that depends on itself is reported at the formula, with the part of it at fault marked.

Never fail on a description, however odd. Whatever a person can enter must at worst be reported. A pattern that cannot be read matches nothing, and is said to be at fault; it does not stop the interface that uses it.

Fit the records to a schema that has changed. Keep every value that is still of its field's kind, drop what belongs to a field that is gone, and start a new field as a new record would. A value that only breaks a new limit is kept and reported, not thrown away: the limit may be the mistake.

Follow a renaming. A field given a new key is the same field, and keeps its values; so is an option, or a kind of record, given a new value. A renaming is told from a removal and an addition by its place: the new name stands where the old one stood, and the old one is gone.

Write a schema out clean. A description holds every setting, set or not; the schema made from it states only those that are set, so that it reads as one written by hand, and comes back from storage as it went.

Say that a description cannot hold a rule. A description is data. A schema that refers to itself through a rule in a program has no description that ends; to describe one, what it refers to needs a name.

See also Schemas, the object editor and the data grid.

Behaviour

Describing a schema

A schema's description is data: its kind, every setting of that kind, and nothing where none is stated
expect(fromSchema(schema({ kind: "number", label: "Price", minimum: 0, optional: true }))).toEqual({
  kind: "number",
  label: "Price",
  formula: null,
  minimum: 0,
  maximum: null,
  step: null,
  precision: null,
  format: null,
  currency: null,
  dimension: null,
  unit: null,
  default: null,
  description: null,
  optional: true,
  readOnly: false,
});
A record's description lists its fields, each a key and the description of its schema, and a list's holds its item's
const description = fromSchema(product) as { fields: Array<{ key: string; schema: { kind: string } }> };
expect(description.fields.map((field) => `${field.key}: ${field.schema.kind}`)).toEqual([
  "name: text",
  "price: number",
  "status: choice",
  "released: date",
  "tags: list",
]);
expect(description.fields[4]!.schema).toMatchObject({
  label: "Tag",
  maxItems: 3,
  item: { kind: "text" },
});
expect(description.fields[2]!.schema).toMatchObject({
  options: [
    { value: "draft", label: "Draft" },
    { value: "live", label: "Live" },
  ],
  default: "draft",
});
Each setting is of its own kind: a number's limits are numbers, a date's are dates, a pattern is text, a format is a choice
const settingOf = (kind: string, key: string) => schemaAt(schemaSchema, [key], { kind });
expect(settingOf("number", "minimum")?.kind).toBe("number");
expect(settingOf("date", "minimum")?.kind).toBe("date");
expect(settingOf("number", "default")?.kind).toBe("number");
expect(settingOf("date", "default")?.kind).toBe("date");
expect(settingOf("boolean", "default")).toMatchObject({ kind: "boolean", optional: true });
expect(settingOf("text", "pattern")?.kind).toBe("text");
expect(settingOf("text", "multiline")?.kind).toBe("boolean");
expect(settingOf("text", "format")?.kind).toBe("choice");
expect(settingOf("text", "maxLength")).toMatchObject({ kind: "number", minimum: 0, precision: 0 });
expect(settingOf("choice", "options")).toMatchObject({
  kind: "list",
  item: { kind: "object", fields: [{ key: "value" }, { key: "label" }] },
});
// No kind has another's settings.
expect(settingOf("date", "pattern")).toBeUndefined();
Every kind has a label, a description, and whether it is optional or read-only; and every setting says what it is called
for (const variant of schemaSchema.variants) {
  const keys: string[] = variant.schema.fields.map((field) => field.key);
  expect(keys).toEqual(expect.arrayContaining(["label", "description", "optional", "readOnly"]));
  for (const field of variant.schema.fields)
    expect(resolve(field.schema).label).toEqual(expect.any(String));
}
A record's fields and a list's item are the schema of schemas again, met only when a value reaches them
const record = {
  kind: "object",
  fields: [{ key: "tags", schema: { kind: "list", item: { kind: "text" } } }],
};
expect(schemaAt(schemaSchema, ["fields", 0, "schema"], record)).toMatchObject({
  kind: "variant",
  tag: "kind",
  variants: schemaSchema.variants,
});
expect(schemaAt(schemaSchema, ["fields", 0, "schema", "item", "pattern"], record)?.kind).toBe("text");
// Stated within itself, it still starts somewhere: as a text with nothing set.
expect(blank(schemaSchema)).toEqual(fromSchema({ kind: "text" }));
expect(validate(schemaSchema, blank(schemaSchema))).toEqual([]);
A description that breaks its schema is reported where it does, like any value
const said = validate(schemaSchema, {
  ...fromSchema(product),
  fields: [
    { key: "", schema: { ...fromSchema({ kind: "number" }), precision: 40, currency: "¥" } },
    { key: "when", schema: { ...fromSchema({ kind: "date" }), minimum: "2026-02-30" } },
    { key: "what", schema: { kind: "colour" } },
  ],
}).map((issue) => `${issue.path.join(".")}: ${issue.code}`);
expect(said).toEqual([
  "fields.0.key: required",
  "fields.0.schema.precision: maximum",
  "fields.0.schema.currency: pattern",
  "fields.1.schema.minimum: type",
  "fields.2.schema.kind: option",
]);
The type of a description is one of the kinds, told apart by kind
const describeIt = (value: SchemaValue): string =>
  value.kind === "number"
    ? `a number of at least ${value.minimum}`
    : value.kind === "choice"
      ? `one of ${value.options.map((option) => option.label).join(", ")}`
      : value.kind;
expect(describeIt(fromSchema({ kind: "number", minimum: 1 }))).toBe("a number of at least 1");
expectTypeOf<Extract<SchemaValue, { kind: "date" }>["minimum"]>().toEqualTypeOf<string | null>();
expectTypeOf<Extract<SchemaValue, { kind: "text" }>["multiline"]>().toEqualTypeOf<boolean>();
// Where a description holds another, the type stops: a schema that refers to itself has no type to read.
expectTypeOf<Extract<SchemaValue, { kind: "list" }>["item"]>().toEqualTypeOf<unknown>();

Turning a description into a schema

The schema a description turns into states only what is set: nothing absent, no empty text, no flag that is off
const description = {
  ...fromSchema({ kind: "text" }),
  label: "Name",
  description: "",
  maxLength: 40,
  minLength: 0,
  multiline: false,
  optional: false,
  readOnly: true,
} as SchemaValue;
expect(toSchema(description)).toEqual({
  kind: "text",
  label: "Name",
  minLength: 0,
  maxLength: 40,
  readOnly: true,
});
expect(Object.keys(toSchema(description))).toEqual([
  "kind",
  "label",
  "minLength",
  "maxLength",
  "readOnly",
]);
A yes-or-no that starts as no keeps saying so: only a flag's no is the same as nothing
const starts = toSchema({ ...fromSchema({ kind: "boolean" }), default: false } as SchemaValue);
expect(starts).toEqual({ kind: "boolean", default: false });
Every schema's description is one the schema of schemas allows, and turns back into the same schema
fc.assert(
  fc.property(schemas, (definition) => {
    const description = fromSchema(definition);
    expect(validate(schemaSchema, description)).toEqual([]);
    expect(toSchema(description)).toEqual(definition);
  }),
);
expect(toSchema(fromSchema(product))).toEqual(product);
A description is plain data: it survives being written out and read back
fc.assert(
  fc.property(schemas, (definition) => {
    const description = fromSchema(definition);
    // JSON has no negative zero, and nothing else here is beyond it.
    const written = JSON.parse(JSON.stringify(description)) as SchemaValue;
    expect(written).toEqual(JSON.parse(JSON.stringify(fromSchema(toSchema(written)))));
    expect(validate(schemaSchema, written)).toEqual([]);
  }),
);
A description the schema of schemas allows always turns into a schema that can be put to use, whatever its limits say
fc.assert(
  fc.property(schemas, fc.jsonValue(), (definition, value) => {
    const made = toSchema(fromSchema(definition));
    // None of it throws: not a blank value, not checking it, not fitting any value to it.
    const start = blank(made);
    validate(made, start);
    validate(made, conform(made, value));
    defaultIssues(made);
    // Nor reading and showing a value of it as text, as a field or a cell does.
    const codec = textCodec(made, "en-US");
    codec.format(codec.parse("12").ok ? 12 : start);
    // And where nothing is found wrong with it, no default of its is at fault.
    if (checkSchema(made).length === 0) expect(defaultIssues(made)).toEqual([]);
  }),
);
A reference is described as what it refers to, with what the reference states of its own
const sum = schema({ kind: "number", label: "Amount", minimum: 0 });
const order = schema({
  kind: "object",
  fields: [{ key: "owed", schema: lazy(() => sum, { label: "Owed", optional: true }) }],
});
expect(toSchema(fromSchema(order))).toEqual({
  kind: "object",
  fields: [{ key: "owed", schema: { kind: "number", label: "Owed", minimum: 0, optional: true } }],
});
A schema that contains itself has no description that ends, and is refused
const folder: Schema = schema({
  kind: "object",
  fields: [{ key: "folders", schema: { kind: "list", item: lazy((): Schema => folder) } }],
});
expect(() => fromSchema(folder)).toThrow("contains itself");
expect(() => fromSchema(schemaSchema)).toThrow("contains itself");
// One reference met twice, side by side, is not a schema within itself.
const text = lazy(() => schema({ kind: "text" }));
const pair = schema({
  kind: "object",
  fields: [
    { key: "first", schema: text },
    { key: "second", schema: text },
  ],
});
expect(fromSchema(pair)).toMatchObject({
  fields: [{ schema: { kind: "text" } }, { schema: { kind: "text" } }],
});
expect(fromSchema(pair)).toEqual(fromSchema(pair));
A schema that contains itself is described by giving what it refers to a name

Not yet written.

What is not a description is refused
expect(() => toSchema(null as unknown as SchemaValue)).toThrow(TypeError);
expect(() => toSchema({ kind: "colour" } as unknown as SchemaValue)).toThrow("Not the description");
The schema of a record's schema alone is the record kind's own fields
const { kind: _, ...settings } = fromSchema(product) as Record<string, unknown>;
expect(validate(recordSchema, settings)).toEqual([]);
expect(recordSchema.fields.map((field) => field.key)).toEqual([
  "label",
  "fields",
  "description",
  "optional",
  "readOnly",
]);

Checking what a description cannot state

A lower limit above its upper one is found, at the lower
expect(found({ kind: "number", minimum: 10, maximum: 5 })).toEqual(["minimum: maximum"]);
expect(found({ kind: "date", minimum: "2026-12-31", maximum: "2026-01-01" })).toEqual([
  "minimum: maximum",
]);
expect(found({ kind: "text", minLength: 5, maxLength: 3 })).toEqual(["minLength: maximum"]);
expect(found({ kind: "list", item: { kind: "text" }, minItems: 2, maxItems: 1 })).toEqual([
  "minItems: maximum",
]);
expect(found({ kind: "number", minimum: 5, maximum: 5 })).toEqual([]);
A default its own schema does not allow is found at the default, as the problem it is
expect(found({ kind: "number", minimum: 1, default: 0 })).toEqual(["default: minimum"]);
expect(found({ kind: "text", maxLength: 3, default: "Teapot" })).toEqual(["default: maxLength"]);
expect(found({ kind: "date", maximum: "2026-01-01", default: "2026-06-01" })).toEqual([
  "default: maximum",
]);
expect(found({ kind: "choice", options: [{ value: "kg", label: "kg" }], default: "lb" })).toEqual([
  "default: option",
]);
expect(found({ kind: "variant", tag: "type", variants: [], default: "circle" })).toEqual([
  "default: option",
]);
expect(found({ kind: "boolean", default: false })).toEqual([]);
A pattern that is not a regular expression is found at the pattern
expect(found({ kind: "text", pattern: "[a-z" })).toEqual(["pattern: pattern"]);
expect(found({ kind: "text", pattern: "[a-z]+" })).toEqual([]);
An amount of money with no currency is found at the currency
expect(found({ kind: "number", format: "currency" })).toEqual(["currency: required"]);
expect(found({ kind: "number", format: "currency", currency: "USD" })).toEqual([]);
expect(found({ kind: "number", format: "percent" })).toEqual([]);
A name used before is found at its later use: a record's keys, a choice's values, a variant's values
const text = { kind: "text" } as const;
expect(
  found({
    kind: "object",
    fields: [
      { key: "name", schema: text },
      { key: "code", schema: text },
      { key: "name", schema: text },
    ],
  }),
).toEqual(["fields.2.key: unique"]);
expect(
  found({
    kind: "choice",
    options: [
      { value: "a", label: "A" },
      { value: "a", label: "Another" },
    ],
  }),
).toEqual(["options.1.value: unique"]);
expect(
  found({
    kind: "variant",
    tag: "type",
    variants: [
      { value: "a", label: "A", schema: { kind: "object", fields: [] } },
      { value: "a", label: "Another", schema: { kind: "object", fields: [] } },
    ],
  }),
).toEqual(["variants.1.value: unique"]);
A formula that cannot be used is found at its field's formula, with why and the part it is about
const number = (formula?: string) =>
  ({ kind: "number", ...(formula !== undefined && { formula }) }) as const;
const record = (formula: string) => ({
  kind: "object",
  fields: [
    { key: "price", schema: number() },
    { key: "quantity", schema: number() },
    { key: "total", schema: number(formula) },
  ],
});
expect(checkSchema(record("price * quantity") as Schema)).toEqual([]);
expect(checkSchema(record("price * cost") as Schema)).toEqual([
  {
    path: ["fields", 2, "schema", "formula"],
    code: "formula",
    message: "There is no field “cost”.",
    at: 8,
    length: 4,
  },
]);
// However deep the record: within a list's item, as here, or a variant's.
expect(found({ kind: "list", item: record("price *") } as Schema)).toEqual([
  "item.fields.2.schema.formula: formula",
]);
// A key used twice is the problem to put right first: no formula is read against it.
expect(
  found({
    kind: "object",
    fields: [
      { key: "price", schema: number() },
      { key: "price", schema: number("price") },
    ],
  } as Schema),
).toEqual(["fields.1.key: unique"]);
A variant's field may not be called what its tag is, nor two of one variant's fields the same; two variants may share a name
const text = { kind: "text" } as const;
expect(
  found({
    kind: "variant",
    tag: "type",
    variants: [
      {
        value: "a",
        label: "A",
        schema: {
          kind: "object",
          fields: [
            { key: "note", schema: text },
            { key: "type", schema: text },
          ],
        },
      },
      {
        value: "b",
        label: "B",
        schema: {
          kind: "object",
          fields: [
            { key: "note", schema: text },
            { key: "note", schema: text },
          ],
        },
      },
    ],
  }),
).toEqual(["variants.0.fields.1.key: unique", "variants.1.fields.1.key: unique"]);
A problem is found however deep it lies, at the path of its part in the schema's description
const order = schema({
  kind: "object",
  fields: [
    { key: "id", schema: { kind: "text" } },
    {
      key: "lines",
      schema: {
        kind: "list",
        item: {
          kind: "variant",
          tag: "type",
          variants: [
            {
              value: "item",
              label: "Item",
              schema: {
                kind: "object",
                fields: [{ key: "quantity", schema: { kind: "number", minimum: 1, default: 0 } }],
              },
            },
          ],
        },
      },
    },
  ],
});
const path = ["fields", 1, "schema", "item", "variants", 0, "fields", 0, "schema", "default"];
expect(checkSchema(order)).toEqual([{ path, code: "minimum" }]);
// The path is one into the description, where an editor of it shows the problem.
expect(
  path.reduce<unknown>((part, key) => (part as Record<string, unknown>)[key], fromSchema(order)),
).toBe(0);
expect(schemaAt(schemaSchema, path, fromSchema(order))).toMatchObject({
  kind: "number",
  label: "Starts as",
});
A schema that contains itself is checked once through, and the check ends
const folder: Schema = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", minLength: 3, maxLength: 1 } },
    { key: "folders", schema: { kind: "list", item: lazy((): Schema => folder) } },
  ],
});
expect(found(folder)).toEqual(["fields.0.schema.minLength: maximum"]);
expect(checkSchema(schemaSchema)).toEqual([]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/schema-schema.json
Source

src/primitives/schema-schema.ts

/**
 * The schema that describes schemas. A schema is data, so it has a shape of its
 * own — a kind, and the settings that kind takes — and that shape can be stated
 * as a schema: a variant on `kind`, whose fields and items are, by reference,
 * the same schema again. Whatever edits a value from its schema then edits
 * schemas too.
 *
 * A description is a value of `schemaSchema`: plain data, every setting present,
 * what is unset held as nothing. `toSchema` turns one into the definition it
 * describes and `fromSchema` goes back. `checkSchema` finds what a description
 * can hold that a schema cannot state — a minimum above its maximum, a name
 * used twice.
 */
import { schemaDerivations } from "./derived.ts";
import { blank, lazy, patternOf, resolve, schema, validate, variantOf } from "./schema.ts";
import type { Infer, Issue, ObjectSchema, Path, Schema } from "./schema.ts";

const label = {
  key: "label",
  schema: { kind: "text", label: "Label", optional: true, description: "What people call this value." },
} as const;

// A single value can be computed from its record's other fields; a record or a list cannot.
const formula = {
  key: "formula",
  schema: {
    kind: "text",
    label: "Formula",
    optional: true,
    description: "How it is computed from the record's other fields, as price * quantity.",
  },
} as const;

const shared = [
  {
    key: "description",
    schema: {
      kind: "text",
      label: "Description",
      optional: true,
      multiline: true,
      description: "A sentence of help: what the value is for, or the form it takes.",
    },
  },
  {
    key: "optional",
    schema: {
      kind: "boolean",
      label: "Optional",
      default: false,
      description: "May be left without a value.",
    },
  },
  {
    key: "readOnly",
    schema: {
      kind: "boolean",
      label: "Read-only",
      default: false,
      description: "Shown, but not for changing.",
    },
  },
] as const;

/** A whole number of things, none or more. */
const count = (name: string, description: string) =>
  ({ kind: "number", label: name, description, optional: true, minimum: 0, precision: 0 }) as const;

// The schema within itself: read when a value reaches it, so stating it ends.
const itself = (name: string, description?: string) =>
  lazy((): Schema => schemaSchema, { label: name, ...(description !== undefined && { description }) });

const fields = {
  key: "fields",
  schema: {
    kind: "list",
    label: "Field",
    description: "The fields, in the order they are shown.",
    item: {
      kind: "object",
      fields: [
        {
          key: "key",
          schema: { kind: "text", label: "Key", description: "The name its value is held under." },
        },
        { key: "schema", schema: itself("Kind") },
      ],
    },
  },
} as const;

/**
 * The schema of schemas: one variant for each kind a schema can be, holding that
 * kind's own settings between its label and what every kind shares.
 *
 * A reference (`lazy`) is not among them: it is a function, not data. One is
 * described as what it refers to.
 */
export const schemaSchema = schema({
  kind: "variant",
  label: "Kind",
  tag: "kind",
  default: "text",
  variants: [
    {
      value: "text",
      label: "Text",
      schema: {
        kind: "object",
        fields: [
          label,
          formula,
          { key: "minLength", schema: count("Shortest", "In characters as people count them.") },
          { key: "maxLength", schema: count("Longest", "In characters as people count them.") },
          {
            key: "pattern",
            schema: {
              kind: "text",
              label: "Pattern",
              optional: true,
              description: "A regular expression the whole text must match.",
            },
          },
          {
            key: "multiline",
            schema: { kind: "boolean", label: "Several lines", default: false },
          },
          {
            key: "format",
            schema: {
              kind: "choice",
              label: "Holds",
              optional: true,
              description: "What the text is, for its keyboard and for filling it in.",
              options: [
                { value: "email", label: "An email address" },
                { value: "url", label: "A link" },
                { value: "tel", label: "A telephone number" },
                { value: "name", label: "A person's name" },
                { value: "given-name", label: "A given name" },
                { value: "family-name", label: "A family name" },
                { value: "organization", label: "An organisation" },
                { value: "street-address", label: "A street address" },
                { value: "postal-code", label: "A postal code" },
                { value: "username", label: "A username" },
              ],
            },
          },
          { key: "default", schema: { kind: "text", label: "Starts as", optional: true } },
          ...shared,
        ],
      },
    },
    {
      value: "number",
      label: "Number",
      schema: {
        kind: "object",
        fields: [
          label,
          formula,
          { key: "minimum", schema: { kind: "number", label: "Least", optional: true } },
          { key: "maximum", schema: { kind: "number", label: "Most", optional: true } },
          {
            key: "step",
            schema: {
              kind: "number",
              label: "Step",
              optional: true,
              minimum: 0,
              description: "How far an arrow key moves it.",
            },
          },
          {
            key: "precision",
            schema: {
              kind: "number",
              label: "Decimal places",
              optional: true,
              minimum: 0,
              maximum: 20,
              precision: 0,
            },
          },
          {
            key: "format",
            schema: {
              kind: "choice",
              label: "Shown as",
              optional: true,
              options: [
                { value: "decimal", label: "A number" },
                { value: "percent", label: "A percentage" },
                { value: "currency", label: "An amount of money" },
              ],
            },
          },
          {
            key: "currency",
            schema: {
              kind: "text",
              label: "Currency",
              optional: true,
              pattern: "[A-Za-z]{3}",
              description: "Its three-letter code, as USD or JPY.",
            },
          },
          {
            key: "dimension",
            schema: {
              kind: "choice",
              label: "Measures",
              optional: true,
              options: [
                { value: "length", label: "A length" },
                { value: "angle", label: "An angle" },
              ],
            },
          },
          {
            key: "unit",
            schema: {
              kind: "text",
              label: "Unit",
              optional: true,
              description: "What it is shown in, as mm or deg.",
            },
          },
          { key: "default", schema: { kind: "number", label: "Starts as", optional: true } },
          ...shared,
        ],
      },
    },
    {
      value: "date",
      label: "Date",
      schema: {
        kind: "object",
        fields: [
          label,
          formula,
          { key: "minimum", schema: { kind: "date", label: "Earliest", optional: true } },
          { key: "maximum", schema: { kind: "date", label: "Latest", optional: true } },
          { key: "default", schema: { kind: "date", label: "Starts as", optional: true } },
          ...shared,
        ],
      },
    },
    {
      value: "boolean",
      label: "Yes or no",
      schema: {
        kind: "object",
        fields: [
          label,
          formula,
          { key: "default", schema: { kind: "boolean", label: "Starts as", optional: true } },
          ...shared,
        ],
      },
    },
    {
      value: "choice",
      label: "Choice",
      schema: {
        kind: "object",
        fields: [
          label,
          formula,
          {
            key: "options",
            schema: {
              kind: "list",
              label: "Option",
              item: {
                kind: "object",
                fields: [
                  {
                    key: "value",
                    schema: { kind: "text", label: "Value", description: "What the data holds." },
                  },
                  {
                    key: "label",
                    schema: { kind: "text", label: "Label", description: "What people see." },
                  },
                ],
              },
            },
          },
          {
            key: "default",
            schema: {
              kind: "text",
              label: "Starts as",
              optional: true,
              description: "The value of one of the options.",
            },
          },
          ...shared,
        ],
      },
    },
    { value: "object", label: "Record", schema: { kind: "object", fields: [label, fields, ...shared] } },
    {
      value: "list",
      label: "List",
      schema: {
        kind: "object",
        fields: [
          {
            key: "label",
            schema: {
              kind: "text",
              label: "Label",
              optional: true,
              description: "What one item is called: its items are named by it, as Tag 2.",
            },
          },
          { key: "item", schema: itself("Each item") },
          { key: "minItems", schema: count("Fewest", "How many items there must be.") },
          { key: "maxItems", schema: count("Most", "How many items there may be.") },
          ...shared,
        ],
      },
    },
    {
      value: "variant",
      label: "One of several records",
      schema: {
        kind: "object",
        fields: [
          label,
          {
            key: "tag",
            schema: {
              kind: "text",
              label: "Tag",
              description: "The key under which a value says which of them it is.",
            },
          },
          {
            key: "variants",
            schema: {
              kind: "list",
              label: "Variant",
              item: {
                kind: "object",
                fields: [
                  {
                    key: "value",
                    schema: { kind: "text", label: "Value", description: "What the tag holds." },
                  },
                  {
                    key: "label",
                    schema: { kind: "text", label: "Label", description: "What people see." },
                  },
                  fields,
                ],
              },
            },
          },
          {
            key: "default",
            schema: {
              kind: "text",
              label: "Starts as",
              optional: true,
              description: "The value of one of the variants.",
            },
          },
          ...shared,
        ],
      },
    },
  ],
});

/**
 * A schema as data. Where a description holds another — a field's, an item's —
 * TypeScript knows it only as `unknown`: the type cannot be read from a schema
 * that refers to itself.
 */
export type SchemaValue = Infer<typeof schemaSchema>;

/**
 * The schema of a record's schema alone, which is what a form or a grid is
 * built from: the record variant's own fields. Its value is a description
 * whose kind is `object`.
 */
export const recordSchema: ObjectSchema = variantOf(schemaSchema, "object")!.schema;

/**
 * The schema of one field of a record — its key, and its schema — which is what
 * an inspector shows of the field picked out of a list of them.
 */
export const fieldSchema: ObjectSchema = fields.schema.item;

type Described = Record<string, unknown>;

const toFields = (described: unknown): ObjectSchema["fields"] =>
  (described as readonly Described[]).map((field) => ({
    key: field.key as string,
    schema: toSchema(field.schema as SchemaValue),
  }));

/**
 * The schema a description describes, with nothing unset in it: what is absent,
 * an empty text, and a yes-or-no that is only its default of no are left out, so
 * the result reads as a definition written by hand. The description must be one
 * `schemaSchema` allows.
 */
export function toSchema(description: SchemaValue): Schema {
  const described = description as Described | null;
  const variant = variantOf(schemaSchema, described?.kind);
  if (described === null || variant === undefined) throw new TypeError("Not the description of a schema.");
  const definition: Described = { kind: variant.value };
  for (const field of variant.schema.fields) {
    const held = described[field.key];
    if (held === null || held === undefined || held === "") continue;
    // A flag that is off says what leaving it out says; a default of no does not.
    if (held === false && field.schema.kind === "boolean" && !("optional" in field.schema)) continue;
    if (field.key === "fields") definition.fields = toFields(held);
    else if (field.key === "item") definition.item = toSchema(held as SchemaValue);
    else if (field.key === "options") {
      definition.options = (held as readonly Described[]).map((option) => ({
        value: option.value,
        label: option.label,
      }));
    } else if (field.key === "variants") {
      definition.variants = (held as readonly Described[]).map((entry) => ({
        value: entry.value,
        label: entry.label,
        schema: { kind: "object", fields: toFields(entry.fields) },
      }));
    } else definition[field.key] = held;
  }
  return definition as unknown as Schema;
}

// The references being followed: one met again while it is leads back to itself.
const following: Schema[] = [];

const fromFields = (stated: ObjectSchema["fields"]): Described[] =>
  stated.map((field) => ({ key: field.key, schema: fromSchema(field.schema) }));

/**
 * The description of a schema: every setting of its kind, with nothing where the
 * schema states none. A reference is described as what it refers to, with what
 * the reference states of its own; a schema that contains itself has no
 * description that ends, and throws.
 */
export function fromSchema(stated: Schema): SchemaValue {
  if (stated.kind !== "lazy") return describe(stated);
  if (following.includes(stated)) {
    throw new RangeError("A schema that contains itself cannot be described as data.");
  }
  following.push(stated);
  try {
    return describe(stated);
  } finally {
    following.pop();
  }
}

function describe(stated: Schema): SchemaValue {
  const definition = resolve(stated);
  const source = definition as unknown as Described;
  const description: Described = { kind: definition.kind };
  for (const field of variantOf(schemaSchema, definition.kind)!.schema.fields) {
    const held = source[field.key];
    if (definition.kind === "object" && field.key === "fields") {
      description.fields = fromFields(definition.fields);
    } else if (definition.kind === "list" && field.key === "item") {
      description.item = fromSchema(definition.item);
    } else if (definition.kind === "choice" && field.key === "options") {
      description.options = definition.options.map((option) => ({
        value: option.value,
        label: option.label,
      }));
    } else if (definition.kind === "variant" && field.key === "variants") {
      description.variants = definition.variants.map((variant) => ({
        value: variant.value,
        label: variant.label,
        fields: fromFields(variant.schema.fields),
      }));
    } else description[field.key] = held ?? blank(field.schema);
  }
  return description as SchemaValue;
}

/** An issue for each name after the first to use it. */
function repeats<N>(
  named: readonly N[],
  nameOf: (entry: N) => string,
  pathOf: (index: number) => Path,
  issues: Issue[],
  taken?: string,
): void {
  for (let index = 0; index < named.length; index += 1) {
    const name = nameOf(named[index]!);
    let used = name === taken;
    for (let earlier = 0; earlier < index && !used; earlier += 1) used = nameOf(named[earlier]!) === name;
    if (used) issues.push({ path: pathOf(index), code: "unique" });
  }
}

const keyOf = (field: ObjectSchema["fields"][number]) => field.key;
const valueOf = (entry: { readonly value: string }) => entry.value;

function inspect(stated: Schema, path: Path, issues: Issue[], seen: Set<Schema>): void {
  const definition = resolve(stated);
  if (stated.kind === "lazy") {
    // What a reference leads to is looked at once, so a schema within itself ends.
    if (seen.has(definition)) return;
    seen.add(definition);
  } else if (path.length === 0) seen.add(definition); // The whole schema is itself one that may be met again.
  // Said of the lower limit, whose own schema names no upper one to confuse it with.
  const above = (lower: string, least: number | string | undefined, most: number | string | undefined) => {
    if (least !== undefined && most !== undefined && least > most) {
      issues.push({ path: [...path, lower], code: "maximum" });
    }
  };
  const startsAs = () => {
    if (!("default" in definition) || definition.default === undefined) return;
    const code = validate(definition, definition.default)[0]?.code;
    if (code !== undefined) issues.push({ path: [...path, "default"], code });
  };
  const fieldsAt = (listed: ObjectSchema["fields"], at: Path, tag?: string) => {
    const before = issues.length;
    repeats(listed, keyOf, (index) => [...at, index, "key"], issues, tag);
    // Formulas are read against the fields' names, which must each name one field.
    if (issues.length === before) {
      for (const { key, message, at: start, length } of schemaDerivations({ kind: "object", fields: listed })
        .problems) {
        const index = listed.findIndex((field) => field.key === key);
        issues.push({
          path: [...at, index, "schema", "formula"],
          code: "formula",
          message,
          at: start,
          length,
        });
      }
    }
    listed.forEach((field, index) => inspect(field.schema, [...at, index, "schema"], issues, seen));
  };

  switch (definition.kind) {
    case "number":
      above("minimum", definition.minimum, definition.maximum);
      if (definition.format === "currency" && definition.currency === undefined) {
        issues.push({ path: [...path, "currency"], code: "required" });
      }
      return startsAs();
    case "date":
      above("minimum", definition.minimum, definition.maximum);
      return startsAs();
    case "text":
      above("minLength", definition.minLength, definition.maxLength);
      if (definition.pattern !== undefined && patternOf(definition.pattern) === null) {
        issues.push({ path: [...path, "pattern"], code: "pattern" });
      }
      return startsAs();
    case "choice":
      repeats(definition.options, valueOf, (index) => [...path, "options", index, "value"], issues);
      return startsAs();
    case "object":
      return fieldsAt(definition.fields, [...path, "fields"]);
    case "list":
      above("minItems", definition.minItems, definition.maxItems);
      return inspect(definition.item, [...path, "item"], issues, seen);
    case "variant":
      repeats(definition.variants, valueOf, (index) => [...path, "variants", index, "value"], issues);
      if (definition.default !== undefined && variantOf(definition, definition.default) === undefined) {
        issues.push({ path: [...path, "default"], code: "option" });
      }
      definition.variants.forEach((variant, index) =>
        // A field called what the tag is would be the tag.
        fieldsAt(variant.schema.fields, [...path, "variants", index, "fields"], definition.tag),
      );
      return;
    case "boolean":
  }
}

/**
 * What is wrong with a schema that `schemaSchema` cannot state as a limit, each
 * at the path of the part in the schema's description (`fromSchema`) that is at
 * fault, so an editor of the description can show it there:
 *
 * - a lower limit above its upper one — `maximum`, at the lower;
 * - a default its own schema does not allow — `validate`'s code, at `default`;
 * - a pattern that is not a regular expression — `pattern`;
 * - a format of currency with no currency — `required`, at `currency`;
 * - a name used before — `unique`: a record's keys, a choice's or a variant's
 *   values, and a variant's field called what its tag is;
 * - a formula that cannot be used — `formula`, at the field's `formula`, with
 *   the reason and the part of the formula it is about (`schemaDerivations`).
 *   A record's formulas are read once its keys are each used once.
 */
export function checkSchema(definition: Schema): Issue[] {
  const issues: Issue[] = [];
  inspect(definition, [], issues, new Set());
  return issues;
}