One Formly form, end to end
This is the complete path through Formly Contract, using one form that is compiled and checked in this repository—not a disconnected documentation sample. You will see the form first, then follow the exact code that owns it, connect it to a workspace, generate it repeatedly, and read what the resulting contract actually says.
Start with the thing a person sees
Section titled “Start with the thing a person sees”The maintained example is a small contact-preferences fragment from the Angular CLI workspace fixture. It combines an ordinary Formly input with an application-owned radio type and an expansion-panel wrapper.
How should we reach the claimant?
Confirm the claimant and choose one preferred contact channel.
The shell around the controls is intentionally presentational. The field
structure, labels, required state, option labels and values, type alias, and
wrapper configuration come from the maintained example. The populated name,
selected radio value, and expanded panel deliberately show an illustrative
after-interaction state. They are not fixture or contract evidence: the
maintained source starts with model: {}, and its wrapper starts collapsed.
This is the same separation you see in Formly’s official advanced-layout and
Material stepper examples: Formly owns field configuration and state; the
application or UI package owns the visual composition.
The application owns this definition. Formly Contract does not replace the form, add a parallel DSL, or scrape the rendered DOM:
import type { FormlyFieldConfig } from '@ngx-formly/core';
export function createContactFragment(): FormlyFieldConfig[] { return [ { key: 'claimant.name', type: 'input', id: 'claimant-name', props: { label: 'Claimant name', required: true }, }, { key: 'claimant.contactPreference', type: 'cool-radio-btn-grp', id: 'contact-preference', wrappers: ['fixture-expansion-panel'], props: { label: 'Preferred contact method', required: true, options: [ { label: 'Email', value: 'email' }, { label: 'Phone', value: 'phone' }, ], }, }, ];}Already present in ordinary Formly configuration are the model paths, labels, required constraints, choices, Formly types, wrapper, and DOM IDs. The compiler projects that evidence conservatively—it does not fill in missing meaning.
1. Author the form
Section titled “1. Author the form”Keep form factories with the application or library that owns them. A contract factory must return fresh fields and safe model state whenever generation calls it. It must not fetch customer data, contact remote services, or depend on a browser-only bootstrap path.
The same createContactFragment() factory can be composed into a real Angular
page and exposed to contract generation. There is no copied field tree to
become stale.
The browser side remains ordinary Angular/Formly code. The maintained custom
type extends FieldType, binds Formly’s formControl, and forwards
formlyAttributes to the actual input. Those are the important integration
points; the surrounding markup and CSS are application-owned:
@Component({ selector: 'fixture-cool-radio-button-group', template: ` <fieldset class="cool-radio-group" role="radiogroup"> <legend>{{ props.label }}</legend> @for (option of props.options ?? []; track option.value) { <label> <input type="radio" [name]="field.name ?? id" [value]="option.value" [formControl]="formControl" [formlyAttributes]="field" /> <span>{{ option.label }}</span> </label> } </fieldset> `,})export class CoolRadioButtonGroupComponent extends FieldType< FieldTypeConfig<CoolRadioProps>> {}Declare the alias and its contract together
Section titled “Declare the alias and its contract together”Formly registration tells Formly which component renders an alias. It does not tell Formly Contract whether that component is a radio group, what its model value looks like, or which reviewed interaction is safe. Declare those semantics once with the browser-safe authoring entry point, then derive both the Formly type registration and canonical contract profile from that declaration:
import { buildFieldTypeProfileRegistry, defineContractedFormlyType, defineContractedFormlyWrapper, radioChoice,} from '@formly-contract/schema/field-type-authoring';
export const FIXTURE_COOL_RADIO_TYPE = defineContractedFormlyType({ name: 'cool-radio-btn-grp', profile: { id: 'fixture.cool-radio', version: 1 }, behavior: radioChoice(),});
export const FIXTURE_EXPANSION_PANEL_WRAPPER = defineContractedFormlyWrapper({ name: 'fixture-expansion-panel', profile: { id: 'fixture.expansion-panel-wrapper', version: 1 }, activation: { part: 'wrapper-expand', operation: 'click', role: 'button', },});radioChoice() is a reviewed behavior declaration, not component
introspection. It lowers to the complete radio-group profile shown later,
including projected props.options, generic.choice, and the check
operation. The wrapper declaration lowers to the required activation
precondition. If the component behaves differently, choose a matching preset
or keep an explicit reviewed profile with unknowns.
Register custom UI where Angular boots
Section titled “Register custom UI where Angular boots”Registration still happens in ordinary Angular/Formly code. The shared type
definition supplies the exact alias to toFormlyTypeRegistration(...). Formly
does not have an equivalent contracted-wrapper registration helper, so the
wrapper registration remains the normal Formly object while reusing the
declaration’s exact name.
This maintained fixture supports Formly 6.x and is NgModule-based. Its
application calls FormlyModule.forRoot(...) once; the feature library uses
FormlyModule.forChild(...) for its aliases:
import { toFormlyTypeRegistration,} from '@formly-contract/schema/field-type-authoring';
import { FIXTURE_COOL_RADIO_TYPE, FIXTURE_EXPANSION_PANEL_WRAPPER,} from './field-type-profiles.js';
@NgModule({ declarations: [ CoolRadioButtonGroupComponent, FixtureExpansionPanelWrapperComponent, ], imports: [ ReactiveFormsModule, FormlyModule.forChild({ types: [ toFormlyTypeRegistration( FIXTURE_COOL_RADIO_TYPE, CoolRadioButtonGroupComponent, ), ], wrappers: [ { name: FIXTURE_EXPANSION_PANEL_WRAPPER.name, component: FixtureExpansionPanelWrapperComponent, }, ], }), ],})export class FormsKitModule {}The result supplies the same aliases used by the field definition:
{ type: 'cool-radio-btn-grp', wrappers: ['fixture-expansion-panel'],}If every use of a custom type always needs the same wrapper, extend the derived
Formly type registration with
wrappers: [FIXTURE_EXPANSION_PANEL_WRAPPER.name]. Keep the wrapper on the
individual field, as this fixture does, when wrapping is a per-usage decision.
Formly’s API also accepts the component class directly, but stable string
aliases are the useful case for shared or JSON-powered field configuration—and
they are what Formly Contract can record as declared evidence.
See Formly 6’s official guides for the underlying custom type and custom wrapper patterns. Formly 7 has different standalone provider APIs; this package’s published peer range is Formly 6.x, so this walkthrough does not present the v7 provider surface as an equivalent supported setup.
2. Connect it
Section titled “2. Connect it”Three small declarations connect application code to a workspace. Each one has one job.
Give the form a stable identity
Section titled “Give the form a stable identity”The source groups related forms and assigns a stable formId. The ID is the
semantic handle consumers use even when the generated filename changes.
import { defineFormContractSource } from '@formly-contract/workspace';import { createContactFragment } from './fragments/contact.fragment.js';
export const SHARED_FORMS_SOURCE = defineFormContractSource({ sourceId: 'fixture/shared-forms', list: () => [ { id: 'shared.contact-preferences', create: () => ({ fields: createContactFragment(), model: {} }), }, ],});Use source boundaries that match feature or library ownership. They are not required to mirror every folder.
Put the source in an owning project
Section titled “Put the source in an owning project”The project descriptor attaches the source and the reviewed custom-field profiles owned by this library:
import { defineFormContractProject } from '@formly-contract/workspace';import { FIXTURE_FIELD_TYPE_PROFILES, SHARED_FORMS_SOURCE,} from '@fixture/forms-kit/contracts';
export default defineFormContractProject({ projectId: 'fixture-forms-kit', sources: [SHARED_FORMS_SOURCE], fieldTypeProfiles: FIXTURE_FIELD_TYPE_PROFILES,});The custom cool-radio-btn-grp becomes meaningful when the project attaches
the registry generated from the same declarations used above. The maintained
fixture combines this generated walkthrough slice with explicit profiles for
other controls whose richer semantics do not fit the current compact presets:
const generatedWalkthroughProfiles = buildFieldTypeProfileRegistry({ id: 'fixture.angular-fields', version: 1, types: [FIXTURE_COOL_RADIO_TYPE], wrappers: [FIXTURE_EXPANSION_PANEL_WRAPPER],});
export const FIXTURE_FIELD_TYPE_PROFILES = { schemaVersion: '0.4.0', id: 'fixture.angular-fields', version: 1, profiles: [ ...generatedWalkthroughProfiles.profiles, // Other maintained explicit profiles are omitted here. ], registrations: [ ...generatedWalkthroughProfiles.registrations, // Other maintained explicit registrations are omitted here. ], wrappers: generatedWalkthroughProfiles.wrappers,};The builder generates both joins:
type: 'cool-radio-btn-grp'→registrations[].formlyType→fixture.cool-radio→ the single-choice profile.wrappers: ['fixture-expansion-panel']→wrappers[].wrapperName→ thewrapper-expandactivation precondition.
Without the first mapping, compilation reports UNMAPPED_FIELD_TYPE and the
generated node will not contain the interaction profile shown later. Without
the wrapper profile, the contract cannot preserve the required expansion step.
Profiles describe reviewed semantics; they are not executable Playwright
implementations.
Let the root discover projects
Section titled “Let the root discover projects”The root config sets workspace-wide policy and output location:
import { defineConfig } from '@formly-contract/workspace';
export default defineConfig({ projectConfigs: [ 'apps/**/formly-contracts.project.ts', 'libs/**/formly-contracts.project.ts', ], tsconfigPath: 'tsconfig.json', output: { directory: 'dist/formly-contracts' }, diagnostics: { failOn: ['error'] },});Use the TypeScript config that owns aliases imported by the Node-safe contract
entry points: commonly tsconfig.json in Angular CLI or tsconfig.base.json
in Nx.
3. Generate and regenerate
Section titled “3. Generate and regenerate”Start with discovery. list loads configuration and inventories projects and
sources without calling the form factories:
pnpm exec formly-contracts listThen generate the artifact set and verify it is current:
pnpm exec formly-contracts generatepnpm exec formly-contracts checkgenerate validates stable IDs, calls the trusted factories, writes each
content-addressed contract, and publishes workspace-index.json last. check
performs the same extraction in memory and exact-compares canonical bytes
without changing the output directory.
dist/formly-contracts/├── workspace-index.json└── projects/ └── id_Zml4dHVyZS1mb3Jtcy1raXQ/ └── forms/ └── id_c2hhcmVkLmNvbnRhY3QtcHJlZmVyZW5jZXM/ └── sha256-322b…e6ca.contract.jsonDo not construct that encoded path yourself. Look up stable IDs in the index
and open its recorded artifactPath:
{ "formId": "shared.contact-preferences", "projectId": "fixture-forms-kit", "sourceId": "fixture/shared-forms", "contractSchemaVersion": "0.4.0", "contentHash": "sha256:322b444e514927b3dbccaf9271e581d8fe7222dfed2c804dcdd96de143e6e6ca", "artifactPath": "dist/formly-contracts/projects/id_Zml4dHVyZS1mb3Jtcy1raXQ/forms/id_c2hhcmVkLmNvbnRhY3QtcHJlZmVyZW5jZXM/sha256-322b444e514927b3dbccaf9271e581d8fe7222dfed2c804dcdd96de143e6e6ca.contract.json"}The hash changes when canonical contract content changes. formId,
projectId, and sourceId are stable joins across generations.
4. Read the contract
Section titled “4. Read the contract”Here is the generated node for the application-owned radio field. This is a
formatted excerpt of the canonical
shared.contact-preferences golden:
{ "id": "shared.contact-preferences::path:s_claimant.s_contactPreference", "kind": "control", "modelPath": ["claimant", "contactPreference"], "formlyType": "cool-radio-btn-grp", "semanticType": "single-choice", "presentation": { "label": "Preferred contact method" }, "constraints": [{ "kind": "required" }], "options": [ { "label": "Email", "value": "email" }, { "label": "Phone", "value": "phone" } ], "valueDomain": { "kind": "enumerated", "values": ["email", "phone"], "completeness": "complete", "source": "adapter", "evidence": "declared" }, "locators": [ { "strategy": "domId", "value": "contact-preference", "target": "control", "confidence": "derived", "evidence": "declared" } ], "wrappers": ["fixture-expansion-panel"], "interactionProfile": { "profile": { "id": "fixture.cool-radio", "version": 1 }, "interaction": { "kind": "choice", "operation": "check", "optionPart": "option" }, "preconditions": [ { "kind": "activate", "part": "wrapper-expand", "operation": "click", "evidence": "declared" } ], "driver": { "kind": "generic", "id": "generic.choice", "version": 1, "capabilities": ["check"] }, "unknowns": [] }, "evidence": "declared"}What each part connects
Section titled “What each part connects”formId+modelPath- Stable semantic identity: this is the contact preference in this form, independent of DOM layout.
presentation+constraints- The human label and required rule projected from Formly props.
options+valueDomain- The two legal values are known and the profile says the set is complete.
locators- The application supplied an ID. The contract records declared, derived locator evidence—not a selector invented later.
interactionProfile- The custom component behaves as a single choice: check one option with the reviewed generic choice driver contract.
preconditions- The expansion wrapper must be activated before its radio option is available.
evidence+unknowns- Every claim states where it came from. Missing knowledge remains explicit instead of being silently guessed.
contentHash- The whole contract has deterministic content identity, so stale references and changed artifacts can be detected.
The form-level diagnostics array for this example is empty. That does not
mean all forms are always fully understood. Unsupported callbacks, async
values, unmapped types, or incomplete effect analysis appear as stable
diagnostics and unknowns in other artifacts.
5. Use it without guessing
Section titled “5. Use it without guessing”A consumer begins with stable semantic intent, not a selector:
Set claimant.contactPreference to "email"The contract supplies the chain of authority:
shared.contact-preferences └─ modelPath: claimant.contactPreference ├─ legal value: email ├─ precondition: click wrapper-expand ├─ operation: check option ├─ trusted driver contract: generic.choice@1 └─ declared locator candidate: #contact-preferenceFor this exact field, the current typed-intent validator stops there. Its
declared wrapper activation precondition cannot yet be expanded into a lossless
plan step, so validation returns UNSUPPORTED_INTERACTION instead of silently
dropping the required click. The example is inspectable contract evidence, but
it is intentionally refused for execution today.
For fields inside the currently supported synthetic proof slice, the remaining path is deliberately fail-closed:
That is the important shift. An agent or test author does not inspect the page
and improvise page.locator('.radio:nth-child(1)'). It asks for a semantic
field, verifies the requested value is inside a complete domain, observes the
wrapper precondition, and refuses when exact evidence is absent.
Current package surfaces can query an assembled agent-context dataset, validate the supported typed-intent subset, revalidate its canonical plan, and bind approved steps to exact trusted driver calls. The private Playwright package does not invoke those calls in a browser, and the CLI does not yet assemble or expose the query dataset. Generated JSON is usable context today; production MCP transport and browser execution remain future layers.
The standalone plan-hash helper also strict-parses before hashing. Proxy, accessor, hidden, cyclic, and unknown-key input is rejected; a valid canonical plan retains the same deterministic hash.
check in CI. Let consumers follow stable identities and explicit evidence.
For field-by-field DTO details, continue to Artifacts and source linkage. For alternate repository layouts, compare the maintained examples.