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.