@mackehansson/schemaform
Guides

Useful Helpers

Built-in authoring helpers and copy-ready utilities for advanced builder work.

Schemaform includes the common authoring helpers directly. Start with Author With Helpers when you want the guided path:

import {
  defineForm,
  field,
  scope,
  control,
  page,
  group,
  columns,
  repeater,
  rule,
} from "@mackehansson/schemaform";

Use these first when creating definitions in app code. The remaining snippets on this page are copy-ready utilities for custom builder surfaces that need to inspect or generate low-level definition nodes.

Key generation

When adding a field you must supply a unique property key. These helpers pick a non-colliding key by appending an incrementing number.

generateKey

For top-level fields:

import type { FormDefinition } from "@mackehansson/schemaform";

function generateKey(def: FormDefinition, base: string): string {
  const existing = new Set(Object.keys(def.schema.properties ?? {}));
  let key = base;
  let n = 2;
  while (existing.has(key)) key = `${base}${n++}`;
  return key;
}

Usage:

const key = generateKey(definition, "text");
operations.addField(pageIndex, {
  key,
  label: "Text field",
  widget: "text",
  schema: { type: "string" },
});

generateRepeaterKey

For fields inside a repeater's row template:

import type { FormDefinition } from "@mackehansson/schemaform";

function generateRepeaterKey(
  def: FormDefinition,
  repeaterKey: string,
  base: string,
): string {
  const items = def.schema.properties?.[repeaterKey]?.items as
    | { properties?: Record<string, unknown> }
    | undefined;
  const existing = new Set(Object.keys(items?.properties ?? {}));
  let key = base;
  let n = 2;
  while (existing.has(key)) key = `${base}${n++}`;
  return key;
}

Usage:

const repeaterKey = repeaterScope.replace("#/properties/", "");
const key = generateRepeaterKey(definition, repeaterKey, "text");
operations.addRepeaterField(repeaterScope, {
  key,
  label: "Text field",
  widget: "text",
  schema: { type: "string" },
});

Finding nodes

When you track a selected field by its scope string (e.g. "#/properties/email") and need to read the corresponding ControlNode — for example to populate a settings panel — walk the UI schema:

findControl

import type { ControlNode, FormDefinition, UiNode } from "@mackehansson/schemaform";

function findControl(def: FormDefinition, scope: string): ControlNode | null {
  for (const page of def.uiSchema.pages) {
    const found = findControlInNodes(page.children, scope);
    if (found) return found;
  }
  return null;
}

function findControlInNodes(
  nodes: UiNode[],
  scope: string,
): ControlNode | null {
  for (const node of nodes) {
    if (node.type === "Control" && node.scope === scope) return node;
    if (node.type === "Group" || node.type === "Columns") {
      const found = findControlInNodes(node.children, scope);
      if (found) return found;
    }
  }
  return null;
}

Usage:

const control = findControl(definition, "#/properties/email");
// control.label, control.widget, …

If you use Repeaters, extend findControlInNodes to also recurse into node.rowTemplate when node.type === "Array".

Scope Utilities

A scope is a JSON Pointer into the JSON Schema, e.g. "#/properties/email". Use the built-in scope("email") helper when creating controls or rules from field keys:

import { scope } from "@mackehansson/schemaform";

const emailScope = scope("email"); // "#/properties/email"

The helpers below go in the other direction: they parse existing persisted scopes when building custom inspector or settings UI.

scopeToKey

Extracts the top-level property key from a scope:

function scopeToKey(scope: string): string {
  const withoutHash = scope.startsWith("#/") ? scope.slice(2) : scope;
  const segments = withoutHash.split("/");
  // segments: ["properties", "email"] → "email"
  const key = segments[1];
  if (segments[0] !== "properties" || !key) {
    throw new Error(
      `Unsupported scope "${scope}" — expected "#/properties/<name>"`,
    );
  }
  return key;
}

Usage:

const key = scopeToKey("#/properties/email"); // "email"
const schema = definition.schema.properties?.[key];

scopeToPath

For nested scopes (#/properties/address/properties/street), returns the full key path as an array:

function scopeToPath(scope: string): string[] {
  const withoutHash = scope.startsWith("#/") ? scope.slice(2) : scope;
  const segments = withoutHash.split("/");
  const path: string[] = [];
  for (let i = 0; i < segments.length; i += 2) {
    if (segments[i] !== "properties" || segments[i + 1] === undefined) {
      throw new Error(
        `Unsupported scope "${scope}" — expected "#/properties/<name>" segments`,
      );
    }
    path.push(segments[i + 1]!);
  }
  if (path.length === 0) throw new Error(`Empty scope "${scope}"`);
  return path;
}

Usage:

scopeToPath("#/properties/address/properties/street"); // ["address", "street"]

Empty definition factory

Every builder and test starts by constructing an empty FormDefinition. This factory keeps the format version in one place:

import { SCHEMAFORM_FORMAT_VERSION, type FormDefinition } from "@mackehansson/schemaform";

function createEmptyDefinition(firstPageTitle = "Page 1"): FormDefinition {
  return {
    formatVersion: SCHEMAFORM_FORMAT_VERSION,
    schema: { type: "object", properties: {}, required: [] },
    uiSchema: {
      pages: [{ type: "Page", title: firstPageTitle, children: [] }],
    },
    rules: [],
  };
}

Usage:

const { definition, operations } = useFormBuilder({
  initialDefinition: createEmptyDefinition("Contact details"),
});

Field inventory

Returns a flat list of every top-level field across all pages — useful for building a field picker in a rule condition editor, a reorder panel, or any UI that needs to enumerate what the form contains.

import type {
  ControlNode,
  FormDefinition,
  JsonSchema,
  UiNode,
} from "@mackehansson/schemaform";

interface FieldInfo {
  scope: string;
  key: string;
  schema: JsonSchema;
  label?: string;
  widget?: string;
}

function collectAllFields(def: FormDefinition): FieldInfo[] {
  const seen = new Set<string>();
  const fields: FieldInfo[] = [];

  function walk(nodes: UiNode[]) {
    for (const node of nodes) {
      if (node.type === "Control") {
        if (!seen.has(node.scope)) {
          seen.add(node.scope);
          const key = node.scope.replace("#/properties/", "");
          const schema = def.schema.properties?.[key] ?? {};
          fields.push({
            scope: node.scope,
            key,
            schema,
            ...(node.label !== undefined ? { label: node.label } : {}),
            ...(node.widget !== undefined ? { widget: node.widget } : {}),
          });
        }
      } else if (node.type === "Group" || node.type === "Columns") {
        walk(node.children);
      }
    }
  }

  for (const page of def.uiSchema.pages) {
    walk(page.children);
  }

  return fields;
}

Usage:

const fields = collectAllFields(definition);
// Build a dropdown for a rule condition editor:
// [{ scope: "#/properties/email", key: "email", schema: { type: "string" }, label: "Email" }, …]

The seen set deduplicates scopes that appear on more than one page. Repeater fields (inside Array nodes) are not included — they live under items.properties and carry row-relative scopes. Extend walk to recurse into node.rowTemplate if you need them.

On this page