Foundations

Schemas

Describing structured data once, so its fields, its checks and its type all follow from one description.

Markdown

A schema describes the shape of a piece of data: what each part is, what it is called, and what it may hold. An interface that edits structured data — a form, an inspector, a table — is easiest to get right when it is derived from one such description, rather than restating it in every field.

Checking a value

A product, as its schema describes it: a name of at most twenty characters, a price not below zero, a status from two options, an optional date, up to three lowercase tags. Edit the value; each problem is reported with where it is.

  • price is below the minimum
  • status is not one of the options
  • released is not the right kind of value
  • tags.1 is not in the expected form
Code
"use client";

import { schema, validate } from "@publictheta/ui/primitives/schema.ts";
import { useState } from "react";

const product = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", maxLength: 20 } },
    { key: "price", schema: { kind: "number", minimum: 0 } },
    {
      key: "status",
      schema: {
        kind: "choice",
        options: [
          { value: "draft", label: "Draft" },
          { value: "live", label: "Live" },
        ],
      },
    },
    { key: "released", schema: { kind: "date", optional: true } },
    { key: "tags", schema: { kind: "list", maxItems: 3, item: { kind: "text", pattern: "[a-z-]+" } } },
  ],
});

const MESSAGES: Record<string, string> = {
  required: "is required",
  type: "is not the right kind of value",
  minimum: "is below the minimum",
  maximum: "is above the maximum",
  minLength: "is too short",
  maxLength: "is too long",
  pattern: "is not in the expected form",
  option: "is not one of the options",
  minItems: "has too few items",
  maxItems: "has too many items",
};

const START = `{
  "name": "Teapot",
  "price": -4800,
  "status": "archived",
  "released": "2026-02-30",
  "tags": ["kitchen", "Gift Idea"]
}`;

export default function SchemaValidate() {
  const [text, setText] = useState(START);
  let parsed: unknown;
  let syntax = false;
  try {
    parsed = JSON.parse(text);
  } catch {
    syntax = true;
  }
  const issues = syntax ? [] : validate(product, parsed);

  return (
    <div className="grid w-full gap-4 sm:grid-cols-2">
      <textarea
        aria-label="Value, as JSON"
        value={text}
        onChange={(event) => setText(event.target.value)}
        spellCheck={false}
        rows={9}
        className="rounded-lg border border-border bg-surface p-3 font-mono text-[13px] leading-relaxed text-fg-strong outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
      />
      <div aria-live="polite" className="text-sm">
        {syntax ? (
          <p className="text-fg-muted">Not JSON yet.</p>
        ) : issues.length === 0 ? (
          <p className="font-medium text-fg-strong">Valid.</p>
        ) : (
          <ul className="flex flex-col gap-1.5">
            {issues.map((issue) => (
              <li key={`${issue.path.join(".")}:${issue.code}`}>
                <code className="rounded bg-surface-muted px-1 py-0.5 font-mono text-[13px] text-fg-strong">
                  {issue.path.join(".") || "value"}
                </code>{" "}
                <span className="text-fg">{MESSAGES[issue.code]}</span>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

Best practices

Describe the data once. The kind of each value, its label, its limits and whether it may be absent belong in one description. The fields shown, the checks applied and the type the program sees all follow from it, so they cannot disagree.

Give every value a kind. A number, a date, text, a yes-or-no, a choice among named options, a record of named fields, a list. The kind decides how a value is read and shown, which control edits it, and what it means to be valid.

Let one of several records be told apart by a tag. Where a value may be one kind of record or another — a card or a transfer, a heading or a paragraph — each kind has its own fields, and the value says which it is in a field kept for that. Only the fields of the kind it is are asked for and checked; a value that names no kind is at fault where it names it. Until one is chosen there is no value at all, unless the schema says which a new record starts as.

Keep what two kinds share when one becomes the other. Changing which kind of record a value is keeps the fields the two have in common — the same key, and a value of the same kind — and starts the rest as a new record does. What only the old kind had is let go.

Let a schema refer to a schema, itself included. A folder holds folders; a comment has replies. The reference is followed when a value reaches it and not before, so a schema that contains itself can be stated, a value of any depth is checked as far as it goes, and a new value starts with nothing where the schema meets itself. What the reference says of its own — its label, whether it is optional — holds in that place only.

Say where a problem is, and what kind it is. A check of a whole record reports each problem with the path to the value at fault — the third tag, the price — and its kind: missing, of the wrong kind, too small, too long. The words for it are the interface's, in its language; the report itself is neutral.

Distinguish absent from empty. A value that may be absent is absent, not zero and not an empty string; a required text that is empty has not been given. Start a new record with nothing entered rather than with invented defaults.

Count characters as people see them. A limit on the length of a text counts what people perceive as characters — a family emoji is one — not the units a computer stores.

Offer choices as values with names. A choice holds a stable value and shows a label; the label may change, or be translated, without changing the data.

Say what a value is for, beside its kind. A description says, in a sentence, what is wanted — shown with the field before anything is wrong. What a text holds — an address for mail, a telephone number, a person's name — chooses the keyboard it is typed on and what a browser may fill in; an address or a link must look like one.

State a default where there is one. A new record starts from the defaults its schema states and from nothing elsewhere; a default is checked against its own schema like any value.

Mark what is only shown. A value that is read-only is shown, checked like any other, and not offered for change — a field, a group or a whole record.

Change data without disturbing what did not change. When one part of a record changes, the rest is the same data as before, so an interface showing it need not redraw, and an unchanged edit is no change at all.

Fit old data to a schema that has changed. Keep each value that is still of its field's kind and start the rest blank; leave a broken limit to be reported rather than discarding the value. Told what the schema was, treat a field renamed in place as the same field.

See also Schemas of schemas, Editing values, Numbers and Dates.

Behaviour

Types

A schema gives the TypeScript type of its values, with nothing to keep in step
expectTypeOf<Infer<typeof product>>().toEqualTypeOf<{
  name: string;
  price: number;
  status: "draft" | "live";
  released: string | null;
  featured: boolean;
  tags: string[];
}>();
A description, a default and being read-only say nothing about the type
const quantity = schema({
  kind: "number",
  description: "How many to send.",
  default: 1,
  readOnly: true,
});
expectTypeOf<Infer<typeof quantity>>().toEqualTypeOf<number>();
A value checked against a schema is known to have its type
const unknown: unknown = JSON.parse(JSON.stringify(valid));
expect(conforms(product, unknown)).toBe(true);
if (conforms(product, unknown)) expectTypeOf(unknown.status).toEqualTypeOf<"draft" | "live">();
A variant gives the type of each of its records, told apart by the tag
expectTypeOf<Infer<typeof shape>>().toEqualTypeOf<
  | { type: "circle"; name: string | null; radius: number }
  | { type: "box"; name: string | null; width: number; height: number }
  | { type: "point" }
>();
const area = (value: Infer<typeof shape>): number =>
  value.type === "circle"
    ? Math.PI * value.radius ** 2
    : value.type === "box"
      ? value.width * value.height
      : 0;
expect(area({ type: "box", name: null, width: 2, height: 3 })).toBe(6);
A reference to a schema stated before it gives that schema's type, optional if the reference says so
const address = schema({ kind: "object", fields: [{ key: "city", schema: { kind: "text" } }] });
const person = schema({
  kind: "object",
  fields: [
    { key: "home", schema: { kind: "lazy", schema: () => address } },
    { key: "work", schema: { kind: "lazy", optional: true, schema: () => address } },
  ],
});
expectTypeOf<Infer<typeof person>>().toEqualTypeOf<{
  home: { city: string };
  work: { city: string } | null;
}>();
Where a schema refers to itself its type is unknown, unless the reference is told it
// TypeScript cannot read a constant's type from its own definition, so what
// the reference returns is said to be a schema, and no more is known of it.
const untold = schema({
  kind: "object",
  fields: [{ key: "next", schema: { kind: "lazy", optional: true, schema: (): Schema => untold } }],
});
expectTypeOf<Infer<typeof untold>>().toEqualTypeOf<{ next: unknown }>();
expectTypeOf<Infer<typeof folder>>().toEqualTypeOf<{ name: string; folders: Folder[] }>();
expectTypeOf<Infer<typeof folder>>().toEqualTypeOf<Folder>();
A schema known only as a schema has values known only as unknown
expectTypeOf<Infer<Schema>>().toEqualTypeOf<unknown>();
expectTypeOf<Infer<ObjectSchema>>().toEqualTypeOf<{ [key: string]: unknown }>();

Validation

A value that meets its schema has no issues
expect(validate(product, valid)).toEqual([]);
An issue says where in the value it is, and what kind of problem it is
expect(codes({ ...valid, price: -1, tags: ["ok", "Not OK"] })).toEqual([
  "price: minimum",
  "tags.1: pattern",
]);
A value that is absent is an issue unless its schema is optional
expect(codes({ ...valid, name: null, released: null })).toEqual(["name: required"]);
expect(codes({ ...valid, featured: undefined })).toEqual(["featured: required"]);
A required text that is empty has not been given
expect(codes({ ...valid, name: "" })).toEqual(["name: required"]);
A value of the wrong kind is an issue of type, and its limits are not checked
expect(codes({ ...valid, price: "4800", featured: "yes", tags: "kitchen" })).toEqual([
  "price: type",
  "featured: type",
  "tags: type",
]);
expect(codes({ ...valid, price: Number.NaN })).toEqual(["price: type"]);
A choice must be one of its options
expect(codes({ ...valid, status: "archived" })).toEqual(["status: option"]);
A date must be a day that exists, within its range
const event = schema({ kind: "date", minimum: "2026-01-01", maximum: "2026-12-31" });
expect(validate(event, "2026-02-30").map((issue) => issue.code)).toEqual(["type"]);
expect(validate(event, "2025-12-31").map((issue) => issue.code)).toEqual(["minimum"]);
expect(validate(event, "2026-10-13")).toEqual([]);
The length of a text is counted in characters as people see them
expect(characterCount("👨‍👩‍👧‍👦é")).toBe(2);
expect(codes({ ...valid, name: "👨‍👩‍👧‍👦".repeat(40) })).toEqual([]);
expect(codes({ ...valid, name: "a".repeat(41) })).toEqual(["name: maxLength"]);
A text said to hold an email address or a URL must look like one
const email = schema({ kind: "text", format: "email" });
expect(validate(email, "ada@example.com")).toEqual([]);
expect(validate(email, "ada at example").map((issue) => issue.code)).toEqual(["pattern"]);
const url = schema({ kind: "text", format: "url" });
expect(validate(url, "https://example.com/a")).toEqual([]);
expect(validate(url, "example").map((issue) => issue.code)).toEqual(["pattern"]);
What else a text is said to hold changes how it is entered, not what is allowed
expect(validate(schema({ kind: "text", format: "tel" }), "call me")).toEqual([]);
expect(validate(schema({ kind: "text", format: "name" }), "X Æ A-12")).toEqual([]);
A read-only value is checked like any other
expect(validate(schema({ kind: "number", minimum: 0, readOnly: true }), -1)).toEqual([
  { path: [], code: "minimum" },
]);
A list is checked for its length and each of its items
expect(codes({ ...valid, tags: ["a", "b", "c", "D"] })).toEqual(["tags: maxItems", "tags.3: pattern"]);
A pattern that is not a regular expression matches nothing, and is an issue rather than an error
expect(said(schema({ kind: "text", pattern: "[a-z" }), "abc")).toEqual([": pattern"]);

Variants

A variant's value is one of several records, checked against the fields of the one its tag names
expect(said(shape, { type: "circle", name: null, radius: 2 })).toEqual([]);
expect(said(shape, { type: "circle", name: null, radius: -2 })).toEqual(["radius: minimum"]);
expect(said(shape, { type: "point" })).toEqual([]);
// A box is not asked for a radius, nor a circle for a width.
expect(said(shape, { type: "box", name: "Lid", width: 3, height: 1, radius: "wide" })).toEqual([]);
expect(said(shape, { type: "circle", name: null, radius: 2, width: -1 })).toEqual([]);
A tag that names none of the variants is an issue at the tag, and no fields are checked
expect(said(shape, { type: "star", radius: -2 })).toEqual(["type: option"]);
expect(said(shape, { radius: 2 })).toEqual(["type: required"]);
expect(said(shape, { type: 3 })).toEqual(["type: type"]);
expect(said(shape, "circle")).toEqual([": type"]);
expect(said(shape, ["circle"])).toEqual([": type"]);
A variant of which none is chosen is absent: an issue unless the variant is optional
expect(said(shape, null)).toEqual([": required"]);
expect(said({ ...shape, optional: true }, null)).toEqual([]);
A variant within a record reports its problems under the record's path to it
const drawing = schema({
  kind: "object",
  fields: [{ key: "shapes", schema: { kind: "list", item: shape } }],
});
expect(
  said(drawing, {
    shapes: [{ type: "point" }, { type: "box", name: null, width: -1, height: 1 }, { type: "?" }],
  }),
).toEqual(["shapes.1.width: minimum", "shapes.2.type: option"]);
A blank variant is not yet chosen, unless its schema states which it starts as
expect(blank(shape)).toBeNull();
expect(blank({ ...shape, default: "box" })).toEqual({
  type: "box",
  name: null,
  width: null,
  height: 1,
});
expect(blank({ ...shape, default: "star" })).toBeNull();
The variant a schema starts as must be one of its variants
expect(defaultIssues({ ...shape, default: "box" })).toEqual([]);
expect(defaultIssues({ ...shape, default: "star" })).toEqual([{ path: [], code: "option" }]);
const faulty = schema({
  kind: "variant",
  tag: "type",
  variants: [
    {
      value: "a",
      label: "A",
      schema: {
        kind: "object",
        fields: [{ key: "n", schema: { kind: "number", maximum: 1, default: 2 } }],
      },
    },
  ],
});
expect(defaultIssues(faulty)).toEqual([{ path: ["n"], code: "maximum" }]);
A path leads to a variant's tag as a choice of the variants, and to a field of the variant the value is
expect(schemaAt(shape, ["type"])).toMatchObject({
  kind: "choice",
  options: [{ value: "circle" }, { value: "box" }, { value: "point" }],
});
const both = schema({
  kind: "variant",
  tag: "as",
  variants: [
    {
      value: "count",
      label: "Count",
      schema: { kind: "object", fields: [{ key: "of", schema: { kind: "number" } }] },
    },
    {
      value: "day",
      label: "Day",
      schema: { kind: "object", fields: [{ key: "of", schema: { kind: "date" } }] },
    },
  ],
});
const wrapped = schema({
  kind: "object",
  fields: [{ key: "items", schema: { kind: "list", item: both } }],
});
const value = { items: [{ as: "day", of: "2026-10-14" }, { as: "count", of: 3 }, { as: "?" }] };
expect(schemaAt(wrapped, ["items", 0, "of"], value)?.kind).toBe("date");
expect(schemaAt(wrapped, ["items", 1, "of"], value)?.kind).toBe("number");
expect(schemaAt(wrapped, ["items", 2, "of"], value)).toBeUndefined();
expect(schemaAt(wrapped, ["items", 0, "missing"], value)).toBeUndefined();
// Without a value there is no telling which: the first variant that has the field.
expect(schemaAt(wrapped, ["items", 0, "of"])?.kind).toBe("number");
expect(valueAt(value, ["items", 0, "of"])).toBe("2026-10-14");
expect(withValueAt(value, ["items", 1, "of"], 4).items[1]).toEqual({ as: "count", of: 4 });
A variant's tag is the same choice every time it is asked for
expect(tagSchema(shape)).toBe(tagSchema(shape));
expect(tagSchema({ ...shape, label: "Shape", optional: true, default: "box" })).toMatchObject({
  label: "Shape",
  optional: true,
  default: "box",
});
A value changed to another variant keeps the fields the two share by key and kind, and starts the rest blank
const circle = { type: "circle", name: "Lid", radius: 4 };
expect(withVariant(shape, circle, "box")).toEqual({ type: "box", name: "Lid", width: null, height: 1 });
expect(withVariant(shape, circle, "point")).toEqual({ type: "point" });
expect(withVariant(shape, null, "circle")).toEqual({ type: "circle", name: null, radius: null });
expect(circle).toEqual({ type: "circle", name: "Lid", radius: 4 });
A field of the same key and another kind is not shared, and one of the same kind is made to fit
const colour = (options: string[]) =>
  ({ kind: "choice", options: options.map((value) => ({ value, label: value })) }) as const;
const limit = schema({
  kind: "variant",
  tag: "of",
  variants: [
    {
      value: "number",
      label: "Number",
      schema: {
        kind: "object",
        fields: [
          { key: "least", schema: { kind: "number" } },
          { key: "from", schema: { kind: "text" } },
          { key: "colour", schema: colour(["red", "blue"]) },
          { key: "size", schema: { kind: "lazy", schema: () => schema({ kind: "number" }) } },
        ],
      },
    },
    {
      value: "date",
      label: "Date",
      schema: {
        kind: "object",
        fields: [
          { key: "least", schema: { kind: "date" } },
          { key: "from", schema: { kind: "date" } },
          { key: "colour", schema: colour(["red", "green"]) },
          { key: "size", schema: { kind: "number", maximum: 1 } },
        ],
      },
    },
  ],
});
// A number is no date, and neither is a text, though this one reads as one; blue
// is not one of the new options; a reference to a number is a number, and a
// limit it now breaks is `validate`'s to report.
const number = { of: "number", least: 3, from: "2026-10-14", colour: "blue", size: 5 };
expect(withVariant(limit, number, "date")).toEqual({
  of: "date",
  least: null,
  from: null,
  colour: null,
  size: 5,
});
expect(withVariant(limit, { ...number, colour: "red" }, "date")).toMatchObject({ colour: "red" });
Changing to the variant a value already is changes nothing, and changing to none leaves nothing chosen
const circle = { type: "circle", name: "Lid", radius: 4 };
expect(withVariant(shape, circle, "circle")).toBe(circle);
expect(withVariant(shape, circle, null)).toBeNull();
// Not one of the variants: there is nothing to change to.
expect(withVariant(shape, circle, "star")).toBe(circle);
Whatever a value held, changed to a variant it has that variant's fields and no others
const variant = fc.constantFrom(...shape.variants);
fc.assert(
  fc.property(fc.jsonValue(), variant, variant, (junk, from, to) => {
    const value = typeof junk === "object" && junk !== null ? { ...junk, type: from.value } : junk;
    const next = withVariant(shape, value, to.value) as Record<string, unknown>;
    if (from === to && value !== junk) {
      expect(next).toBe(value);
      return;
    }
    expect(Object.keys(next).toSorted()).toEqual(
      ["type", ...to.schema.fields.map((field) => field.key)].toSorted(),
    );
    const wrong = validate(shape, next).filter(({ code }) => code === "type" || code === "option");
    expect(wrong).toEqual([]);
  }),
);

References

A reference is checked as the schema it refers to
const name = schema({ kind: "text", maxLength: 3 });
const named = schema({ kind: "object", fields: [{ key: "name", schema: lazy(() => name) }] });
expect(said(named, { name: "Ada" })).toEqual([]);
expect(said(named, { name: "Grace" })).toEqual(["name: maxLength"]);
expect(said(named, { name: null })).toEqual(["name: required"]);
What a reference states of its own — a label, a description, optional, read-only — stands in place of its target's
const name = schema({ kind: "text", label: "Name", description: "As it is spoken.", maxLength: 3 });
const nickname = lazy(() => name, { label: "Nickname", optional: true });
expect(resolve(nickname)).toEqual({
  kind: "text",
  label: "Nickname",
  description: "As it is spoken.",
  maxLength: 3,
  optional: true,
});
expect(validate(nickname, null)).toEqual([]);
expect(validate(nickname, "")).toEqual([]);
// One that states nothing of its own is its target, and no copy of it.
expect(resolve(lazy(() => name))).toBe(name);
expect(resolve(name)).toBe(name);
A reference is read when a value first reaches it, never while the schema is stated, and once
let reads = 0;
const inner = schema({ kind: "number" });
const read = () => {
  reads += 1;
  return inner;
};
const outer = schema({
  kind: "object",
  fields: [
    {
      key: "deep",
      schema: {
        kind: "object",
        optional: true,
        fields: [{ key: "n", schema: lazy(read, { label: "N" }) }],
      },
    },
  ],
});
expect(reads).toBe(0);
expect(validate(outer, { deep: null })).toEqual([]);
expect(reads).toBe(0);
expect(said(outer, { deep: { n: "one" } })).toEqual(["deep.n: type"]);
expect(validate(outer, { deep: { n: 1 } })).toEqual([]);
expect(schemaAt(outer, ["deep", "n"])).toBe(schemaAt(outer, ["deep", "n"]));
expect(reads).toBe(1);
A schema that contains itself checks any value that ends, as deep as the value goes
fc.assert(
  fc.property(folders(4), (value) => {
    expect(validate(folder, value)).toHaveLength(unnamed(value));
  }),
);
expect(
  said(folder, { name: "a", folders: [{ name: "b", folders: [{ name: "", folders: "none" }] }] }),
).toEqual(["folders.0.folders.0.name: required", "folders.0.folders.0.folders: type"]);
A blank value of a schema that contains itself ends: where the schema is met within itself it starts as nothing
expect(blank(folder)).toEqual({ name: null, folders: [] });
const address = schema({ kind: "object", fields: [{ key: "city", schema: { kind: "text" } }] });
const person = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text" } },
    { key: "partner", schema: lazy((): Schema => person) },
    { key: "home", schema: lazy(() => address) },
    { key: "work", schema: lazy(() => address) },
  ],
});
// A reference that does not come round to itself is followed like any other
// part, however many times the schema it leads to is met beside itself.
const couple = schema({
  kind: "object",
  fields: [{ key: "first", schema: lazy(() => person, { label: "First", optional: true }) }],
});
expect(blank(couple)).toEqual({ first: blank(person) });
expect(blank(person)).toEqual({
  name: null,
  partner: null,
  home: { city: null },
  work: { city: null },
});
expect(defaultIssues(person)).toEqual([]);
A path leads through a reference to what it refers to
expect(schemaAt(folder, ["folders", 0, "folders", 3, "name"])).toMatchObject({ kind: "text" });
expect(schemaAt(folder, ["folders", 0])).toBe(folder);
expect(
  schemaAt(
    lazy(() => folder),
    [],
  ),
).toBe(folder);
A reference that leads only back to itself describes nothing, and is refused
const loop: Schema = lazy(() => loop);
expect(() => validate(loop, 1)).toThrow("refers only to itself");
expect(() => blank(loop)).toThrow("refers only to itself");
A reference that could not be read is read again when next it is reached
let ready = false;
const late = lazy(() => {
  if (!ready) throw new Error("not yet");
  return schema({ kind: "number" });
});
expect(() => validate(late, 1)).toThrow("not yet");
ready = true;
expect(validate(late, 1)).toEqual([]);

Fitting a schema that has changed

A value that fits its schema is left as it is: the same value, not a copy
expect(conform(before, teapot)).toBe(teapot);
expect(conform(shape, null)).toBeNull();
const circle = { type: "circle", name: null, radius: 2 };
expect(conform(shape, circle)).toBe(circle);
A field the schema has gained starts as a new record's does, and one it has lost is dropped
const gained: ObjectSchema = {
  kind: "object",
  fields: [...before.fields, { key: "stock", schema: { kind: "number", default: 0 } }],
};
expect(conform(gained, teapot)).toEqual({ ...teapot, stock: 0 });
const { price: _, ...rest } = teapot;
expect(conform(having("price"), teapot)).toEqual(rest);
expect(Object.keys(conform(having("price"), teapot))).not.toContain("price");
A part that is no longer of its schema's kind starts blank; one that only breaks a limit is kept
const dated = having("price", { key: "price", schema: { kind: "date" } });
expect(conform(dated, teapot)).toEqual({ ...teapot, price: null });
const dearer = having("price", { key: "price", schema: { kind: "number", minimum: 100 } });
expect(conform(dearer, teapot)).toBe(teapot);
expect(said(dearer, conform(dearer, teapot))).toEqual(["price: minimum"]);
const fewer = having("status", {
  key: "status",
  schema: { kind: "choice", options: [{ value: "draft", label: "Draft" }], default: "draft" },
});
expect(conform(fewer, teapot)).toEqual({ ...teapot, status: "draft" });
// A text that reads as a date is one; a list where a record should be is not.
expect(conform(schema({ kind: "date" }), "2026-10-14")).toBe("2026-10-14");
expect(conform(schema({ kind: "date" }), "2026-02-30")).toBeNull();
expect(conform(before, { ...teapot, size: [180], tags: "kitchen" })).toEqual({
  ...teapot,
  size: { width: null },
  tags: [],
});
An absent part stays absent where it may be, and starts blank where it may not
const stocked = schema({
  kind: "object",
  fields: [
    { key: "stock", schema: { kind: "number", default: 0 } },
    { key: "spare", schema: { kind: "number", default: 0, optional: true } },
  ],
});
expect(conform(stocked, { stock: null, spare: null })).toEqual({ stock: 0, spare: null });
expect(conform({ ...shape, optional: true }, { type: "star" })).toBeNull();
expect(conform({ ...shape, default: "point" }, { type: "star" })).toEqual({ type: "point" });
Only what had to change is copied: every part that fits is the part it was
const next = conform(having("price", { key: "price", schema: { kind: "date" } }), teapot);
expect(next).not.toBe(teapot);
expect(next.tags).toBe(teapot.tags);
expect(next.size).toBe(teapot.size);
const lines = schema({
  kind: "list",
  item: { kind: "object", fields: [{ key: "n", schema: { kind: "number" } }] },
});
const held = [{ n: 1 }, { n: "two" }, { n: 3 }];
const fitted = conform(lines, held);
expect(fitted).toEqual([{ n: 1 }, { n: null }, { n: 3 }]);
expect(fitted[0]).toBe(held[0]);
expect(fitted[2]).toBe(held[2]);
Given the schema a value was made for, a field renamed in place keeps its value
const renamed = having("price", { key: "cost", schema: { kind: "number", minimum: 0 } });
const { price, ...rest } = teapot;
expect(conform(renamed, teapot, before)).toEqual({ ...rest, cost: price });
// Without the old schema there is no telling a renaming from a removal and an addition.
expect(conform(renamed, teapot)).toEqual({ ...rest, cost: null });
// Renamed and made another kind at once: the value is carried only if it fits.
const redated = having("price", { key: "cost", schema: { kind: "date" } });
expect(conform(redated, teapot, before)).toEqual({ ...rest, cost: null });
An option or a variant renamed in place keeps the values that named it
const relabelled = having("status", {
  key: "status",
  schema: {
    kind: "choice",
    options: [
      { value: "draft", label: "Draft" },
      { value: "published", label: "Published" },
    ],
  },
});
expect(conform(relabelled, teapot, before)).toEqual({ ...teapot, status: "published" });
expect(conform(relabelled, teapot)).toEqual({ ...teapot, status: null });
const round = schema({
  ...shape,
  variants: shape.variants.map((variant) =>
    variant.value === "circle" ? { ...variant, value: "round" } : variant,
  ),
});
expect(conform(round, { type: "circle", name: null, radius: 2 }, shape)).toEqual({
  type: "round",
  name: null,
  radius: 2,
});
A field put where another stood, while that one stays, is not taken for it
const moved: ObjectSchema = {
  kind: "object",
  fields: [
    { key: "price", schema: { kind: "number" } },
    { key: "name", schema: { kind: "text" } },
    { key: "note", schema: { kind: "text" } },
  ],
};
const was: ObjectSchema = {
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text" } },
    { key: "price", schema: { kind: "number" } },
    { key: "code", schema: { kind: "text" } },
  ],
};
const inserted: ObjectSchema = {
  kind: "object",
  fields: [was.fields[0]!, { key: "cost", schema: { kind: "number" } }, was.fields[1]!, was.fields[2]!],
};
const record = { name: "Teapot", price: 48, code: "T-1" };
// Two fields changed places, and are themselves; the third was renamed.
expect(conform(moved, record, was)).toEqual({ price: 48, name: "Teapot", note: "T-1" });
// A field put before the price is new: the price is still the price.
expect(conform(inserted, record, was)).toEqual({ name: "Teapot", cost: null, price: 48, code: "T-1" });
An option that takes the place of one removed is not that one renamed
const options = (...values: string[]) =>
  schema({ kind: "choice", optional: true, options: values.map((value) => ({ value, label: value })) });
expect(conform(options("live", "sold"), "draft", options("draft", "live"))).toBeNull();
expect(conform(options("new", "live"), "draft", options("draft", "live"))).toBe("new");
Whatever a value held, once fitted it is of its schema's shape, and fitting it again changes nothing
const drawing = schema({
  kind: "object",
  fields: [
    { key: "title", schema: { kind: "text", optional: true } },
    { key: "shapes", schema: { kind: "list", item: shape } },
    { key: "root", schema: lazy(() => folder, { optional: true }) },
  ],
});
fc.assert(
  fc.property(
    fc.oneof(
      fc.jsonValue(),
      fc.record({
        title: fc.jsonValue(),
        shapes: fc.array(
          fc.record({ type: fc.constantFrom("circle", "box", "?"), radius: fc.jsonValue() }),
        ),
        root: fc.oneof(fc.jsonValue(), folders(2)),
      }),
    ),
    (value) => {
      const fitted = conform(drawing, value);
      // Nothing is left that is of the wrong kind; a limit may be broken, or a part missing.
      const wrong = validate(drawing, fitted).filter(
        ({ code }) => code === "type" || code === "option",
      );
      expect(wrong).toEqual([]);
      expect(conform(drawing, fitted)).toBe(fitted);
    },
  ),
);

Values

A blank value has every field, with nothing entered
expect(blank(product)).toEqual({
  name: null,
  price: null,
  status: null,
  released: null,
  featured: null,
  tags: [],
});
A blank value starts from a default wherever its schema states one
const line = schema({
  kind: "object",
  fields: [
    { key: "item", schema: { kind: "text" } },
    { key: "quantity", schema: { kind: "number", default: 1 } },
    { key: "gift", schema: { kind: "boolean", default: false } },
  ],
});
expect(blank(line)).toEqual({ item: null, quantity: 1, gift: false });
A default its own schema does not allow is found, at the part that states it
const line = schema({
  kind: "object",
  fields: [
    { key: "quantity", schema: { kind: "number", minimum: 1, default: 0 } },
    { key: "unit", schema: { kind: "choice", options: [{ value: "kg", label: "kg" }], default: "lb" } },
    { key: "note", schema: { kind: "text", default: "none" } },
  ],
});
expect(defaultIssues(line)).toEqual([
  { path: ["quantity"], code: "minimum" },
  { path: ["unit"], code: "option" },
]);
A path leads to the part of a value, and to that part's schema
expect(valueAt(valid, ["tags", 0])).toBe("kitchen");
expect(valueAt(valid, ["missing", "deeper"])).toBeUndefined();
expect(schemaAt(product, ["price"])).toMatchObject({ kind: "number", label: "Price" });
expect(schemaAt(product, ["tags", 2])).toMatchObject({ kind: "text" });
expect(schemaAt(product, ["name", "length"])).toBeUndefined();
Replacing a part copies only what lies along its path
const order = {
  customer: { name: "Ada", address: { city: "London" } },
  lines: [{ quantity: 1 }, { quantity: 2 }],
};
const next = withValueAt(order, ["lines", 1, "quantity"], 5);
expect(next).toEqual({ customer: order.customer, lines: [{ quantity: 1 }, { quantity: 5 }] });
expect(next.customer).toBe(order.customer);
expect(next.lines[0]).toBe(order.lines[0]);
expect(order.lines[1]!.quantity).toBe(2);
Replacing a part with itself returns the same value, so nothing re-renders
expect(withValueAt(valid, ["price"], 4800)).toBe(valid);
expect(withValueAt(valid, ["tags", 0], "kitchen")).toBe(valid);
Replacing a part that does not exist yet makes the way to it
expect(withValueAt({}, ["size", "width"], 10)).toEqual({ size: { width: 10 } });
expect(withValueAt(null, ["tags", 0], "new")).toEqual({ tags: ["new"] });
Replacing a part past the end of a list fills the way with nothing, and leaves no holes
const next = withValueAt(["a"], [3], "d");
expect(next).toEqual(["a", null, null, "d"]);
expect(Object.keys(next)).toEqual(["0", "1", "2", "3"]);
A value of the wrong shape is replaced along the path, never taken apart into it
expect(withValueAt("text", ["name"], "Ada")).toEqual({ name: "Ada" });
expect(withValueAt(["a", "b"], ["name"], "Ada")).toEqual({ name: "Ada" });
expect(withValueAt({ name: "Ada" }, [0], "first")).toEqual(["first"]);

React

Install

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

src/primitives/schema.ts

/**
 * A schema describes the shape of a value as plain data: what kind each part
 * is, what it is called, and what it may hold. Because it is data, an interface
 * can be built from it — a form, an inspector, the columns of a grid — and it
 * can itself be edited by the same interface.
 *
 * Schemas are the single source for three things that otherwise drift apart:
 * the TypeScript type of a value (`Infer`), whether a value is valid
 * (`validate`), and how each part is read, shown and edited.
 *
 * A schema costs nothing at the type level — `Infer` is erased — and nothing at
 * run time beyond the object literal that states it.
 *
 * The kinds combine: a record of fields, a list of items, one of several records
 * told apart by a tag (`variant`), and a schema given by reference (`lazy`), so
 * that one can contain itself. With those a schema can describe schemas.
 */
import { isDate } from "./date-type.ts";

interface Common {
  /** What people call this value. */
  readonly label?: string;
  /** Whether the value may be absent, held as `null`. */
  readonly optional?: boolean;
  /** A sentence of help: what the value is for, or the form it takes. */
  readonly description?: string;
  /** Whether the value is shown but not for changing. It is checked like any other. */
  readonly readOnly?: boolean;
  /**
   * How the value is computed from the record's other fields, as a formula
   * (`price * quantity`): a computed value follows them, and is not typed in
   * unless it is frozen. Read by `schemaDerivations`.
   */
  readonly formula?: string;
}

export interface NumberSchema extends Common {
  readonly kind: "number";
  readonly minimum?: number;
  readonly maximum?: number;
  readonly step?: number;
  readonly precision?: number;
  readonly format?: "decimal" | "percent" | "currency";
  readonly currency?: string;
  /** A unit of the named dimension, as `mm` of `length`. */
  readonly dimension?: "length" | "angle";
  readonly unit?: string;
  /** What a new record starts with, in place of nothing. */
  readonly default?: number;
}

export interface DateSchema extends Common {
  readonly kind: "date";
  /** The earliest and latest dates allowed, as ISO strings. */
  readonly minimum?: string;
  readonly maximum?: string;
  readonly default?: string;
}

export interface TextSchema extends Common {
  readonly kind: "text";
  /** Lengths in characters as people count them, not code units. */
  readonly minLength?: number;
  readonly maxLength?: number;
  /** A regular expression the whole text must match. */
  readonly pattern?: string;
  readonly multiline?: boolean;
  /**
   * What the text holds, so it can be entered with the right keyboard and
   * filled in by the browser. An email address or a URL must also look like one.
   */
  readonly format?: TextFormat;
  readonly default?: string;
}

export type TextFormat =
  | "email"
  | "url"
  | "tel"
  | "name"
  | "given-name"
  | "family-name"
  | "organization"
  | "street-address"
  | "postal-code"
  | "username";

export interface BooleanSchema extends Common {
  readonly kind: "boolean";
  readonly default?: boolean;
}

export interface ChoiceSchema extends Common {
  readonly kind: "choice";
  readonly options: ReadonlyArray<{ readonly value: string; readonly label: string }>;
  /** The value of one of the options. */
  readonly default?: string;
}

export interface ObjectSchema extends Common {
  readonly kind: "object";
  /** The fields, in the order they are shown. */
  readonly fields: ReadonlyArray<{ readonly key: string; readonly schema: Schema }>;
}

export interface ListSchema extends Common {
  readonly kind: "list";
  readonly item: Schema;
  readonly minItems?: number;
  readonly maxItems?: number;
}

/**
 * One of several records, told apart by a tag: a value is a record holding the
 * chosen variant's `value` under `tag`, beside that variant's own fields. No
 * variant has a field with the tag's key.
 */
export interface VariantSchema extends Common {
  readonly kind: "variant";
  /** The key under which a value says which variant it is. */
  readonly tag: string;
  readonly variants: ReadonlyArray<{
    readonly value: string;
    readonly label: string;
    readonly schema: ObjectSchema;
  }>;
  /** The `value` of the variant a new record starts as, in place of none chosen. */
  readonly default?: string;
}

/**
 * A schema given by reference and read only when a value reaches it, so that a
 * schema can contain itself. What the reference states of its own — a label, a
 * description, being optional or read-only — stands in place of what its target
 * states, so one schema can be optional in one place and required in another.
 *
 * `T` is the type of its values where TypeScript cannot work it out: a schema
 * that refers to itself. It is a type only, and nothing is held for it.
 */
export interface LazySchema<T = unknown> extends Common {
  readonly kind: "lazy";
  readonly schema: () => Schema;
  /** Never set: it carries `T` for `Infer`. */
  readonly type?: T;
}

export type Schema =
  | NumberSchema
  | DateSchema
  | TextSchema
  | BooleanSchema
  | ChoiceSchema
  | ObjectSchema
  | ListSchema
  | VariantSchema
  | LazySchema;

/** Any schema but a reference: what a reference leads to. */
export type ResolvedSchema = Exclude<Schema, LazySchema>;

type Flat<T> = { [K in keyof T]: T[K] };

type Fields<S extends ObjectSchema> = {
  -readonly [F in S["fields"][number] as F["key"]]: Infer<F["schema"]>;
};

type DraftFields<S extends ObjectSchema> = {
  -readonly [F in S["fields"][number] as F["key"]]: Draft<F["schema"]>;
};

/** One variant's record: its tag, then its fields. Distributes over the variants. */
type Case<Tag extends string, V> = V extends { readonly value: infer Value; readonly schema: infer O }
  ? O extends ObjectSchema
    ? Flat<{ -readonly [T in Tag]: Value } & Fields<O>>
    : never
  : never;

type DraftCase<Tag extends string, V> = V extends { readonly value: infer Value; readonly schema: infer O }
  ? O extends ObjectSchema
    ? Flat<{ -readonly [T in Tag]: Value } & DraftFields<O>>
    : never
  : never;

/**
 * What a reference's values are: the type it declares; else that of what it
 * returns, where that is known more nearly than as "a schema"; else unknown.
 */
type Referred<S extends LazySchema> =
  S extends LazySchema<infer T>
    ? unknown extends T
      ? Schema extends ReturnType<S["schema"]>
        ? unknown
        : S extends { readonly optional: boolean }
          ? Present<ReturnType<S["schema"]>>
          : Infer<ReturnType<S["schema"]>>
      : T
    : never;

type Present<S extends Schema> = S extends NumberSchema
  ? number
  : S extends DateSchema | TextSchema
    ? string
    : S extends BooleanSchema
      ? boolean
      : S extends ChoiceSchema
        ? S["options"][number]["value"]
        : S extends ObjectSchema
          ? Fields<S>
          : S extends ListSchema
            ? Infer<S["item"]>[]
            : S extends VariantSchema
              ? Case<S["tag"], S["variants"][number]>
              : S extends LazySchema
                ? Referred<S>
                : never;

/** The type of a value the schema describes. */
export type Infer<S extends Schema> = S extends { readonly optional: true } ? Present<S> | null : Present<S>;

/**
 * The type of a value while it is being entered: shaped as the schema says, but
 * with any single value possibly not yet given.
 */
export type Draft<S extends Schema> = S extends ObjectSchema
  ? DraftFields<S>
  : S extends ListSchema
    ? Draft<S["item"]>[]
    : S extends VariantSchema
      ? DraftCase<S["tag"], S["variants"][number]> | null
      : S extends LazySchema<infer T>
        ? unknown extends T
          ? Schema extends ReturnType<S["schema"]>
            ? unknown
            : Draft<ReturnType<S["schema"]>> | null
          : T | null
        : Present<S> | null;

/** States a schema, keeping its literal types so `Infer` can read them. */
export function schema<const S extends Schema>(definition: S): S {
  return definition;
}

/**
 * A reference to a schema, read when a value reaches it. Give `T` where the
 * schema refers to itself — TypeScript cannot work a type out from its own
 * definition — and say what the function returns:
 * `lazy<Folder>((): Schema => folder)`.
 */
export function lazy<T = unknown>(
  target: () => Schema,
  own?: Pick<LazySchema, "label" | "description" | "optional" | "readOnly">,
): LazySchema<T> {
  return { ...own, kind: "lazy", schema: target };
}

export type Path = ReadonlyArray<string | number>;

export interface Issue {
  /** Where in the value the problem is. */
  readonly path: Path;
  /**
   * What is wrong; the words for it belong to the interface and its language.
   * `unique` is never `validate`'s: it is for a check beside a schema, of what a
   * schema cannot state. Nor is `formula`: a formula that cannot be used, with
   * the reader's own words for why and the part of the formula it is about.
   */
  readonly code:
    | "formula"
    | "required"
    | "type"
    | "minimum"
    | "maximum"
    | "minLength"
    | "maxLength"
    | "pattern"
    | "option"
    | "minItems"
    | "maxItems"
    | "unique";
  /** Of a `formula` problem: what is wrong, and where in the formula — UTF-16 indices — to mark. */
  readonly message?: string;
  readonly at?: number;
  readonly length?: number;
}

// A reference is followed once: what it leads to is kept against the reference
// itself, and `null` while it is being followed.
const followed = new WeakMap<LazySchema, ResolvedSchema | null>();

/**
 * What a schema is once any reference is followed: the schema itself, or what a
 * reference leads to with the reference's own label, description, optional and
 * read-only in place of its target's. The same object every time. A reference
 * that leads only back to itself describes nothing, and throws.
 */
export function resolve(definition: Schema): ResolvedSchema {
  if (definition.kind !== "lazy") return definition;
  const known = followed.get(definition);
  if (known !== undefined) {
    if (known === null) throw new Error("A lazy schema refers only to itself.");
    return known;
  }
  followed.set(definition, null);
  let target: ResolvedSchema;
  try {
    target = resolve(definition.schema());
  } finally {
    followed.delete(definition);
  }
  const { label, description, optional, readOnly } = definition;
  if (label !== undefined || description !== undefined || optional !== undefined || readOnly !== undefined) {
    target = {
      ...target,
      ...(label !== undefined && { label }),
      ...(description !== undefined && { description }),
      ...(optional !== undefined && { optional }),
      ...(readOnly !== undefined && { readOnly }),
    };
  }
  followed.set(definition, target);
  return target;
}

type Variant = VariantSchema["variants"][number];

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

/** The variant a tag's value names, if it names one. */
export function variantOf(definition: VariantSchema, tag: unknown): Variant | undefined {
  for (const variant of definition.variants) if (variant.value === tag) return variant;
  return undefined;
}

/** The variant a value is, if it says so. */
function variantIn(definition: VariantSchema, value: unknown): Variant | undefined {
  return isRecord(value) ? variantOf(definition, value[definition.tag]) : undefined;
}

const tags = new WeakMap<VariantSchema, ChoiceSchema>();

/**
 * The choice a variant's tag is: an option for each variant, with the variant
 * schema's own label, description and default, and whether it is optional or
 * read-only. The same object every time.
 */
export function tagSchema(definition: VariantSchema): ChoiceSchema {
  let choice = tags.get(definition);
  if (choice === undefined) {
    const { label, description, optional, readOnly } = definition;
    choice = {
      kind: "choice",
      options: definition.variants,
      ...(label !== undefined && { label }),
      ...(description !== undefined && { description }),
      ...(optional !== undefined && { optional }),
      ...(readOnly !== undefined && { readOnly }),
      ...(definition.default !== undefined && { default: definition.default }),
    };
    tags.set(definition, choice);
  }
  return choice;
}

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });

/** The length of a text in characters as people perceive them. */
export function characterCount(text: string): number {
  let count = 0;
  for (const _ of segmenter.segment(text)) count += 1;
  return count;
}

// Loose on purpose: only the address's own server knows whether it is one.
const EMAIL = /^[^\s@]+@[^\s@]+$/u;

function fitsFormat(format: TextFormat | undefined, text: string): boolean {
  if (format === "email") return EMAIL.test(text);
  if (format === "url") return URL.canParse(text);
  return true;
}

// Each pattern is compiled once; one that is not a regular expression is kept as none.
const patterns = new Map<string, RegExp | null>();

/** A pattern as the expression a whole text must match, or `null` if it is not one. */
export function patternOf(pattern: string): RegExp | null {
  let expression = patterns.get(pattern);
  if (expression === undefined) {
    try {
      expression = new RegExp(`^(?:${pattern})$`, "u");
    } catch {
      expression = null;
    }
    patterns.set(pattern, expression);
  }
  return expression;
}

function check(stated: Schema, value: unknown, path: Path, issues: Issue[]): void {
  const definition = resolve(stated);
  if (value === null || value === undefined) {
    if (definition.optional !== true) issues.push({ path, code: "required" });
    return;
  }
  const fail = (code: Issue["code"]) => issues.push({ path, code });

  switch (definition.kind) {
    case "number":
      if (typeof value !== "number" || !Number.isFinite(value)) return void fail("type");
      if (definition.minimum !== undefined && value < definition.minimum) fail("minimum");
      if (definition.maximum !== undefined && value > definition.maximum) fail("maximum");
      return;
    case "date":
      if (typeof value !== "string" || !isDate(value)) return void fail("type");
      if (definition.minimum !== undefined && value < definition.minimum) fail("minimum");
      if (definition.maximum !== undefined && value > definition.maximum) fail("maximum");
      return;
    case "text": {
      if (typeof value !== "string") return void fail("type");
      // A required text that is empty has not been given.
      if (value === "" && definition.optional !== true) return void fail("required");
      if (definition.minLength !== undefined || definition.maxLength !== undefined) {
        const length = characterCount(value);
        if (definition.minLength !== undefined && length < definition.minLength) fail("minLength");
        if (definition.maxLength !== undefined && length > definition.maxLength) fail("maxLength");
      }
      // Nothing matches a pattern that cannot be read: the fault is said, not thrown.
      if (definition.pattern !== undefined && patternOf(definition.pattern)?.test(value) !== true) {
        fail("pattern");
      } else if (!fitsFormat(definition.format, value)) fail("pattern");
      return;
    }
    case "boolean":
      if (typeof value !== "boolean") fail("type");
      return;
    case "choice":
      if (typeof value !== "string") return void fail("type");
      if (!definition.options.some((option) => option.value === value)) fail("option");
      return;
    case "object":
      if (typeof value !== "object" || Array.isArray(value)) return void fail("type");
      for (const field of definition.fields) {
        check(field.schema, (value as Record<string, unknown>)[field.key], [...path, field.key], issues);
      }
      return;
    case "list":
      if (!Array.isArray(value)) return void fail("type");
      if (definition.minItems !== undefined && value.length < definition.minItems) fail("minItems");
      if (definition.maxItems !== undefined && value.length > definition.maxItems) fail("maxItems");
      value.forEach((item, index) => check(definition.item, item, [...path, index], issues));
      return;
    case "variant": {
      if (typeof value !== "object" || Array.isArray(value)) return void fail("type");
      const record = value as Record<string, unknown>;
      const tag = record[definition.tag];
      const chosen = variantOf(definition, tag);
      if (chosen === undefined) {
        // Which variant it is cannot be told, so no variant's fields are checked.
        const code =
          tag === null || tag === undefined ? "required" : typeof tag === "string" ? "option" : "type";
        issues.push({ path: [...path, definition.tag], code });
        return;
      }
      for (const field of chosen.schema.fields) {
        check(field.schema, record[field.key], [...path, field.key], issues);
      }
    }
  }
}

/**
 * Everything about a value that its schema does not allow; none when it is
 * valid. It goes only as deep as the value does, so a schema that contains
 * itself checks any value that ends.
 */
export function validate(definition: Schema, value: unknown): Issue[] {
  const issues: Issue[] = [];
  check(definition, value, [], issues);
  return issues;
}

/** Whether a value is what the schema describes, as a type guard. */
export function conforms<S extends Schema>(definition: S, value: unknown): value is Infer<S> {
  return validate(definition, value).length === 0;
}

// The records being started, outermost first, each known by its fields: a
// reference's own label makes a copy of its target, which keeps the fields.
// Kept and reused, so starting a value allocates nothing but the value.
const starting: unknown[] = [];

function start(stated: Schema): unknown {
  const definition = resolve(stated);
  if (definition.kind === "list") return [];
  let fields: ObjectSchema["fields"];
  const record: Record<string, unknown> = {};
  if (definition.kind === "object") fields = definition.fields;
  else if (definition.kind === "variant") {
    const chosen = variantOf(definition, definition.default);
    if (chosen === undefined) return null;
    fields = chosen.schema.fields;
    record[definition.tag] = chosen.value;
  } else return definition.default ?? null;
  // Within itself a schema starts as nothing, or a blank value would never end.
  if (starting.includes(fields)) return null;
  starting.push(fields);
  try {
    for (const field of fields) record[field.key] = start(field.schema);
  } finally {
    starting.pop();
  }
  return record;
}

/**
 * A value with nothing entered: every scalar absent unless its schema states a
 * default, every object with its fields, every list empty, every variant not
 * yet chosen unless its schema states which to start as. It is where a new
 * record starts, and is valid only if nothing without a default is required. A
 * schema met again within itself starts as nothing there.
 */
export function blank<S extends Schema>(definition: S): Draft<S> {
  return start(definition) as Draft<S>;
}

function faults(stated: Schema, path: Path, seen: Set<Schema>, issues: Issue[]): void {
  const definition = resolve(stated);
  if (stated.kind === "lazy") {
    // What a reference leads to is looked at once, however often it is met.
    if (seen.has(definition)) return;
    seen.add(definition);
  }
  if (definition.kind === "object") {
    for (const field of definition.fields) faults(field.schema, [...path, field.key], seen, issues);
  } else if (definition.kind === "list") faults(definition.item, [...path, 0], seen, issues);
  else if (definition.kind === "variant") {
    if (definition.default !== undefined && variantOf(definition, definition.default) === undefined) {
      issues.push({ path, code: "option" });
    }
    for (const variant of definition.variants) {
      for (const field of variant.schema.fields) faults(field.schema, [...path, field.key], seen, issues);
    }
  } else if (definition.default !== undefined) {
    for (const issue of validate(definition, definition.default)) issues.push({ path, code: issue.code });
  }
}

/**
 * What is wrong with the defaults a schema states: each issue is `validate`'s,
 * at the path of the part whose default its own schema does not allow. A
 * variant's default must name one of its variants.
 */
export function defaultIssues(definition: Schema, path: Path = []): Issue[] {
  const issues: Issue[] = [];
  faults(definition, path, new Set(), issues);
  return issues;
}

function fieldOf(definition: ObjectSchema, key: string | number): Schema | undefined {
  for (const field of definition.fields) if (field.key === key) return field.schema;
  return undefined;
}

/**
 * The schema of the part of a value at `path`, if the path leads somewhere,
 * with any reference followed. A variant's tag is a choice of its variants. A
 * path into a variant's field needs to know which variant: given the value, the
 * one the value is; without it, the first that has the field.
 */
export function schemaAt(definition: Schema, path: Path, value?: unknown): ResolvedSchema | undefined {
  let current = resolve(definition);
  let held = value;
  for (const key of path) {
    let next: Schema | undefined;
    if (current.kind === "object") next = fieldOf(current, key);
    else if (current.kind === "list" && typeof key === "number") next = current.item;
    else if (current.kind === "variant") {
      if (key === current.tag) next = tagSchema(current);
      else if (value !== undefined) {
        const chosen = variantIn(current, held);
        next = chosen === undefined ? undefined : fieldOf(chosen.schema, key);
      } else {
        for (const variant of current.variants) {
          next = fieldOf(variant.schema, key);
          if (next !== undefined) break;
        }
      }
    }
    if (next === undefined) return undefined;
    current = resolve(next);
    held =
      held === null || typeof held !== "object" ? undefined : (held as Record<string | number, unknown>)[key];
  }
  return current;
}

/** The part of a value at `path`. */
export function valueAt(value: unknown, path: Path): unknown {
  let current = value;
  for (const key of path) {
    if (current === null || typeof current !== "object") return undefined;
    current = (current as Record<string | number, unknown>)[key];
  }
  return current;
}

/**
 * The value with the part at `path` replaced. Only the objects and arrays along
 * the path are copied; everything else is shared with the original, and
 * replacing a part with itself returns the original. Where the value is not of
 * the shape the path asks for — text where a record should be — the way is made
 * anew rather than through it, and a list reached past its end is filled with
 * nothing up to there.
 */
export function withValueAt<T>(value: T, path: Path, next: unknown): T {
  if (path.length === 0) return next as T;
  const [key, ...rest] = path as [string | number, ...Path];
  const listed = typeof key === "number";
  const fits = typeof value === "object" && value !== null && Array.isArray(value) === listed;
  const container = (fits ? value : listed ? [] : {}) as Record<string | number, unknown>;
  const current = container[key];
  const replaced = withValueAt(current, rest, next);
  if (fits && Object.is(replaced, current)) return value;
  if (Array.isArray(container)) {
    const copy = container.slice();
    // Assigning past the end would leave holes, which are not values.
    while (copy.length < (key as number)) copy.push(null);
    copy[key as number] = replaced;
    return copy as T;
  }
  return { ...container, [key]: replaced } as T;
}

/**
 * What something is now, if it was renamed in place: it stood among `before`
 * where something stands whose name `before` did not have, and its own name
 * is gone.
 */
function renamed<P extends string, N extends { readonly [K in P]: string }>(
  now: readonly N[],
  before: readonly N[] | undefined,
  property: P,
  name: unknown,
): N | undefined {
  if (before === undefined) return undefined;
  const candidate = now[before.findIndex((entry) => entry[property] === name)];
  if (candidate === undefined) return undefined;
  // Its name is still in use, or the name now there is an old one moved: not a renaming.
  if (now.some((entry) => entry[property] === name)) return undefined;
  if (before.some((entry) => entry[property] === candidate[property])) return undefined;
  return candidate;
}

function fitFields(
  definition: ObjectSchema,
  record: Record<string, unknown>,
  before: ObjectSchema | undefined,
  tag: string | undefined,
  tagged: string | undefined,
): Record<string, unknown> {
  const { fields } = definition;
  // The same record unless something has to change; then a copy from there on.
  const begin = (upTo: number) => {
    const copy: Record<string, unknown> = {};
    if (tag !== undefined) copy[tag] = tagged;
    for (let index = 0; index < upTo; index += 1) copy[fields[index]!.key] = record[fields[index]!.key];
    return copy;
  };
  let next: Record<string, unknown> | undefined;
  let kept = 0;
  if (tag !== undefined) {
    if (record[tag] === tagged) kept += 1;
    else next = begin(0);
  }
  for (let index = 0; index < fields.length; index += 1) {
    const field = fields[index]!;
    const own = Object.hasOwn(record, field.key);
    let held = record[field.key];
    let was = before === undefined ? undefined : fieldOf(before, field.key);
    if (!own && before !== undefined) {
      const old = before.fields[index];
      if (
        old !== undefined &&
        Object.hasOwn(record, old.key) &&
        renamed(fields, before.fields, "key", old.key) === field
      ) {
        held = record[old.key];
        was = old.schema;
      }
    }
    const fitted = fit(field.schema, held, was);
    if (own) kept += 1;
    if (next === undefined && (!own || !Object.is(fitted, held))) next = begin(index);
    if (next !== undefined) next[field.key] = fitted;
  }
  if (next !== undefined) return next;
  // A key the schema does not have is dropped.
  let count = 0;
  for (const _ in record) count += 1;
  return count === kept ? record : begin(fields.length);
}

function fit(stated: Schema, value: unknown, statedBefore: Schema | undefined): unknown {
  const definition = resolve(stated);
  // What was never there is new, and starts as a new record's does; what is
  // absent stays absent where it may be.
  if (value === undefined) return blank(definition);
  if (value === null) return definition.optional === true ? null : blank(definition);
  const before = statedBefore === undefined ? undefined : resolve(statedBefore);
  switch (definition.kind) {
    case "number":
      return typeof value === "number" && Number.isFinite(value) ? value : blank(definition);
    case "date":
      return typeof value === "string" && isDate(value) ? value : blank(definition);
    case "text":
      return typeof value === "string" ? value : blank(definition);
    case "boolean":
      return typeof value === "boolean" ? value : blank(definition);
    case "choice": {
      if (definition.options.some((option) => option.value === value)) return value;
      const was = before?.kind === "choice" ? before.options : undefined;
      return renamed(definition.options, was, "value", value)?.value ?? blank(definition);
    }
    case "object":
      if (!isRecord(value)) return blank(definition);
      return fitFields(
        definition,
        value,
        before?.kind === "object" ? before : undefined,
        undefined,
        undefined,
      );
    case "list": {
      if (!Array.isArray(value)) return [];
      const was = before?.kind === "list" ? before.item : undefined;
      let next: unknown[] | undefined;
      for (let index = 0; index < value.length; index += 1) {
        const fitted = fit(definition.item, value[index], was);
        if (next === undefined && !Object.is(fitted, value[index])) next = value.slice(0, index);
        next?.push(fitted);
      }
      return next ?? value;
    }
    case "variant": {
      if (!isRecord(value)) return blank(definition);
      const tag = value[definition.tag];
      const was = before?.kind === "variant" && before.tag === definition.tag ? before : undefined;
      const chosen = variantOf(definition, tag) ?? renamed(definition.variants, was?.variants, "value", tag);
      if (chosen === undefined) return blank(definition);
      const old = was === undefined ? undefined : variantOf(was, tag)?.schema;
      return fitFields(chosen.schema, value, old, definition.tag, chosen.value);
    }
  }
}

/**
 * A value made to fit a schema, as when the schema has changed under it: what
 * still fits is kept and the rest starts blank.
 *
 * A part fits when it is of its schema's kind — a number, a real date, one of a
 * choice's options, a record for a record. Its limits are not asked: a price
 * below a new minimum is kept, for `validate` to report. A field the schema no
 * longer has is dropped; one it has gained starts as a new record's does; an
 * absent part stays absent where it may be, and starts blank where it may not.
 *
 * Given the schema the value was made for, a renaming is followed: a field, an
 * option or a variant whose name is new, standing where one whose name is gone
 * stood, is that one under its new name, and keeps its value.
 *
 * A value that already fits is returned as it is, and so is each part that does.
 */
export function conform<S extends Schema>(definition: S, value: unknown, before?: Schema): Draft<S> {
  return fit(definition, value, before) as Draft<S>;
}

/**
 * A variant's value as another of its variants, or as none (`null`). A field
 * the two variants share — the same key, and schemas of the same kind once any
 * reference is followed — keeps its value, made to fit the new field as
 * `conform` does; every other field of the new variant starts blank, and what
 * only the old one had is dropped. Choosing the variant it already is returns
 * the value as it is.
 */
export function withVariant(definition: VariantSchema, value: unknown, tag: string | null): unknown {
  if (tag === null) return null;
  const to = variantOf(definition, tag);
  if (to === undefined) return value;
  const from = variantIn(definition, value);
  if (from === to) return value;
  const next: Record<string, unknown> = { [definition.tag]: to.value };
  for (const field of to.schema.fields) {
    const shared = from === undefined ? undefined : fieldOf(from.schema, field.key);
    next[field.key] =
      shared !== undefined && resolve(shared).kind === resolve(field.schema).kind
        ? // Two variants' fields are not one field before and after: nothing is taken for a renaming.
          fit(field.schema, (value as Record<string, unknown>)[field.key], undefined)
        : blank(field.schema);
  }
  return next;
}