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
| Surface | What type describes |
|---|---|
Store fields | Runtime state. Read {{stores.<name>.<field>}}; write with set-property. |
Schema fields | A named reusable shape. Reference with { "kind": "ref", "refId": "<schemaId>" }. |
| Component / primitive registry props | The element's prop bag, including Function slots. |
| Hook config | useForm.fields (same Field list); other kinds use FieldType on their config keys. |
Action params / returns | The store action's signature. Primitive param catalogs are the same Field list. |
Hook returns | The object at {{hooks.<alias>.*}}. Callable members are kind: "function" — those become "<hookId>.<method>" actions. |
kind values
User-facing labels in parentheses (PROP_TYPE_LABELS).
kind | Shape | Value |
|---|---|---|
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 / variants | Form 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 singleeventReact 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 synchronouscode-actionstore 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.