Context
Composite Types in Forman are types which could be represented by a different structure, compound of multiple other, more primitive building blocks.
For example, when having a field of type udtspec,
{
"type": "udtspec",
"name": "customType"
}
it's a wrapper around a more primitive structure, which looks like this:
{
"type": "array",
"name": "customType",
"spec": [
{
"name": "name",
"label": "Name",
"placeholder": "Enter name",
"type": "text",
"required": true
},
{
"name": "label",
"label": "Label",
"help": "Display name for better readability.",
"type": "text",
"advanced": true
},
{
"name": "help",
"label": "Description",
"type": "text",
"multiline": true,
"required": false,
"placeholder": "Enter description"
},
{
"name": "type",
"label": "Type",
"type": "udttype",
"required": true,
"default": "text"
}
]
}
Workflow
- Composite Types are defined in
src/composites - Each composite needs to be supported in:
- conversion from FormanSchema to JSON Schema
- conversion from JSON Schema to FormanSchema
- validation of FormanSchema
- you need to know the composite structure (= the compound structure of the more primitive fields), associated with the composite type -- ask the operator to provide this.
- based on the top-level type of the expanded structure (in case of
udtspecit'sarray), add this to the type conversion map in both,validatorandformanfiles - then add the handling of this composite type in the type-level switches
- for that, you need to implement functions in the corresponding file in
src/composites/${compositeType}.ts. Each composite must export four functions:${compositeType}Expand— morphs the composite field into its expanded primitive structure. Accepts and mutates the field definition, settingspec,type, etc. Returns the mutated field.${compositeType}ExtractInner— extracts the inner structural fragment from the fully converted JSON Schema to store in$defs. This fragment must NOT contain field-specific title/description. (e.g. for udtspec: returnsschema.items; for udttype: returns schema minus title/description)${compositeType}WrapRef— builds a per-usage wrapper that references the$defsfragment via$refand carries field-specific title/description/default. (e.g. for udtspec:{ type: 'array', title, description, items: { $ref } }; for udttype:{ allOf: [{ $ref }], title, description, default }— usesallOffor draft-07 compliance where siblings of$refare ignored)${compositeType}Collapse— reverse conversion from JSON Schema to Forman (see below)
- then register the composite in the
compositeHandlersconfig insrc/forman.ts:const compositeHandlers = { mytype: { expand: mytypeExpand, extractInner: mytypeExtractInner, wrapRef: mytypeWrapRef }, }; - in the high-level flow:
- when converting Forman to JSON Schema, the
compositeHandlersblock intoJSONSchemaInternalhandles all composites uniformly:x-compositemarker is set viaObject.definePropertyon both the inner fragment in$defsand on every wrapper — this is important for backward conversion
- How
$ref/$defsrecursion prevention works:- The
ConversionContexthas adefs?: Record<string, JSONSchema7>property for collecting definitions $defsstores only the inner structural fragment (no title/description). Title/description stay on each usage's wrapper.- On first encounter: register placeholder in
context.defs[type], expand (with label/help stripped), convert, extract inner viaextractInner, store in$defs, then return wrapper viawrapRef - On subsequent encounters: skip expansion, just return wrapper via
wrapRef— each usage retains its own field-specific metadata - Only add to
defswhen the composite is actually used (lazy) — don't pre-populate$defswith unused definitions - The
toJSONSchemawrapper inindex.tsattaches collectedcontext.defsas$defson the root output, but only if non-empty
- The
- Validation: no recursion protection needed — validation walks the actual data values, which are always finite. Just expand the composite and validate recursively; termination is guaranteed by the data depth.
- when converting Forman to JSON Schema, the
- when validating Forman schema, morph the field, and then continue the validation recursively, as the morphed field is now in the structure of more primitive types
- when converting JSON Schema to Forman, check if the field has
x-compositeproperty, and if it's equal to the composite type you're adding, then morph the field back to the composite structure and don't expand it further- for this conversion, implement the function
${compositeType}Collapse, which will accept the expanded field definition, and will return the composite one, by basically doing the opposite of what${compositeType}Expanddoes - notice that this one accepts JSONSchema Field and returns FormanSchema field
- for this conversion, implement the function
- once done with these steps, make sure to cover the new composite type with tests, both for conversion and validation, following the structure of existing tests
- create the tests in the
test/compositesfolder
- create the tests in the