Codelab
Behavior

Type system

FieldType is the recursive type tree for store fields, hook fields, schemas, registry props, and callable signatures.

Every typed slot is a Field whose type is a recursive FieldType. Leaves are primitives; composites carry payload (object.fields, array.item, ref.refId, and.operands). The discriminator is kind, a Type enum member (packages/modules/schema/kernel/src/schema.field.ts, packages/modules/schema/kernel/src/schema.type-expression.ts).

The write contract is the Zod fieldTypeSchema / fieldsSchema (packages/modules/schema/kernel/src/schema.type-expression.schema.ts) — the same JSONB shape on stores, schemas, components, and hooks.config.useForm.fields.

{
  "id": "p1",
  "name": "modalOpen",
  "type": { "kind": "boolean" },
  "defaultValue": false
}

id is present on persisted top-level entries (store / schema / form fields) and omitted on nested object fields and registry slots.

Where types appear

SurfaceWhat type describes
Store fieldsRuntime state. Read {{stores.<name>.<field>}}; write with set-property.
Schema fieldsA named reusable shape. Reference with { "kind": "ref", "refId": "<schemaId>" }.
Component / primitive registry propsThe element's prop bag, including Function slots.
Hook configuseForm.fields (same Field list); other kinds use FieldType on their config keys.
Action params / returnsThe store action's signature. Primitive param catalogs are the same Field list.
Hook returnsThe object at {{hooks.<alias>.*}}. Callable members are kind: "function" — those become "<hookId>.<method>" actions.

kind values

User-facing labels in parentheses (PROP_TYPE_LABELS).

kindShapeValue
text{ kind: "text", flavor?: "inline" | "rich" }String. flavor is how much markup the widget allows.
number{ kind: "number" }Number. Default 0.
boolean{ kind: "boolean" }Boolean. Default false.
date{ kind: "date" }Date.
file{ kind: "file", accept?: string }A FileBinding { fileId, name?, format? } — not a URL string. accept is a MIME glob (image/*, video/*). An expression that evaluates to a URL is also legal here because the vendor slot is src.
object{ kind: "object", fields: Field[] }Nested record. Holds a value of a shape.
array{ kind: "array", item: FieldType }List. item is any nested FieldType. UI label: List.
ref{ kind: "ref", refId: string }An app Schema (packages/modules/schema/core/src/domain/schema.model.ts). UI label: Custom Type.
and{ kind: "and", operands: FieldType[] }Intersection. Last operand wins on the same field name. UI label: Combined Type.
or{ kind: "or", operands: FieldType[] }Type-level union (string | string[] | void). No stored discriminator.
oneof{ kind: "oneof" } plus field discriminator / variantsForm toggle-group: named variants, each with fields. Distinct from or.
element{ kind: "element" }ElementBinding { elementId }. The renderer hands the vendor a ref; dispatch resolves it to the DOM node (e.g. toast anchor).
component{ kind: "component" }ComponentBinding { componentId, instanceProps? }.
function{ kind: "function", params?, returns?, async?, identity? }FunctionModel { functionId, instanceParams?, when? }. See below.
renderprop{ kind: "renderprop", params: Field[] }Nothing is stored on the slot. The element's children are the callback body; declared params bind as {{render.<name>}} (typically item / index). Return is rendered, not consumed as a value (packages/modules/renderer/core/src/application/render-prop.application.ts).
void{ kind: "void" }Nothing. Only meaningful as a callable's returns. Distinct from omitted returns (undeclared). UI label: Nothing.
interface{ kind: "interface" }The value is a Field list (a shape). useForm's fields config is this: Object holds a value of a shape; Interface holds the shape.

editor on a Field is an optional widget override (input, multiline, select, image-generate), not a type.

Schemas (custom types)

create_schema / update_schema / list_schemas. Name is PascalCase. After create, use the schema id as refId — renaming the schema does not break refs.

{
  "name": "GuestUser",
  "fields": [
    { "id": "p1", "name": "name", "type": { "kind": "text" } },
    { "id": "p2", "name": "dietary", "type": { "kind": "text" } }
  ]
}
{ "kind": "ref", "refId": "<thisSchemaId>" }
{
  "kind": "array",
  "item": { "kind": "ref", "refId": "<thisSchemaId>" }
}
{
  "kind": "and",
  "operands": [
    { "kind": "ref", "refId": "schema_user" },
    {
      "kind": "object",
      "fields": [{ "name": "permissions", "type": { "kind": "text" } }]
    }
  ]
}

A refId that transitively points back at this schema is rejected (SchemaDomain.detectCycle in packages/modules/schema/core/src/domain/schema.domain.ts). Dangling refs are not cycles.

update_schema wholesale-replaces fields. list_schemas (pass schemaId for the full tree), then send the whole array.

kind: "function" — the signature

A Function type carries an optional signature: params (Field list, declaration order), returns (FieldType), async, identity (packages/modules/schema/kernel/src/schema.type-expression.ts).

  • Omitted params → the single event React assumes.
  • Omitted returns → undeclared. { kind: "void" } → returns nothing.
  • Omitted identity → wrap as a handler. identity: true → pass the compiled store callable.
  • async: false → the host reads the return at the call site; only a synchronous code-action store action may bind there.

The value on that slot is always a FunctionModel, never a body. See Function.

A store action or primitive that knows its shape carries the non-partial signature (params / returns on ActionModel; primitive Field catalogs on primitiveWriteRegistry).

Literal vs expression values

A Field's defaultValue (and any prop / instanceParams value) is a literal or an expression — type says what the value is, not whether it is bound.

A whole-binding string "{{stores.cart.price}}" canonicalizes to:

{ "_tag": "Expression", "source": "{{stores.cart.price}}" }

(packages/modules/expression/kernel/src/expression.model.ts.) The write schema tries the expression arm first, then JSON (packages/modules/schema/kernel/src/schema.type-expression.schema.ts).

A field is computed when that expression is paired with editable: false. The pairing is the discriminator — computed is not its own kind. The store keeps the expression as the field's value and evaluates it on every {{stores.*}} read (packages/modules/store/core/src/domain/store.domain.ts). set-property must not write it.

{
  "name": "total",
  "type": { "kind": "number" },
  "defaultValue": "{{stores.cart.price * stores.cart.quantity}}",
  "editable": false
}

An expression defaultValue without editable: false is rejected.

Validation and other Field keys

{
  "name": "email",
  "type": { "kind": "text" },
  "label": "Email",
  "validation": { "required": true, "message": "Email is required" }
}

validation also accepts min / max / minLength / maxLength. hidden keeps the field declared but off the props form (className is owned by the Style panel). options feeds select-style widgets.

update_store wholesale-replaces fields (and actions). update_hook(config) wholesale-replaces the hook config, including useForm.fields. Always list_* first, then send the full array.

On this page