Skip to content

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.

Works today Formly code → source → project → contract Supported intent Hash-pinned context → validated plan → bound driver calls Not shipped Driver invocation in a browser

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.

Maintained fixture Claim contact preferences Fixture-backed controls · illustrative populated state and application shell
Contact details

How should we reach the claimant?

Confirm the claimant and choose one preferred contact channel.

2 required fields
Preferred contact method Expanded
Choose one option
Illustrative after-interaction model { claimant: { name: 'Maya Chen', contactPreference: 'email' } }

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:

libs/forms-kit/src/lib/fragments/contact.fragment.ts
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.

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.

Browser side Angular registers and renders the input, custom radio component, and wrapper.
Build side A Node-safe source calls the same field factory under a stable form ID.

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:

cool-radio-button-group.component.ts (focused excerpt)
@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:

field-type-profiles.ts (maintained fixture)
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.

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:

forms-kit.module.ts (maintained fixture, focused excerpt)
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.

Three small declarations connect application code to a workspace. Each one has one job.

Form definitionWhat the form is
SourceWhich forms belong together
ProjectWho owns sources and profiles
WorkspaceWhat to discover and where to write

The source groups related forms and assigns a stable formId. The ID is the semantic handle consumers use even when the generated filename changes.

libs/forms-kit/src/lib/shared-forms.source.ts
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.

The project descriptor attaches the source and the reviewed custom-field profiles owned by this library:

libs/forms-kit/formly-contracts.project.ts
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:

field-type-profiles.ts (registry lowering)
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:

  1. type: 'cool-radio-btn-grp'registrations[].formlyTypefixture.cool-radio → the single-choice profile.
  2. wrappers: ['fixture-expansion-panel']wrappers[].wrapperName → the wrapper-expand activation 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.

The root config sets workspace-wide policy and output location:

formly-contracts.config.ts
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.

Start with discovery. list loads configuration and inventories projects and sources without calling the form factories:

Terminal window
pnpm exec formly-contracts list

Then generate the artifact set and verify it is current:

Terminal window
pnpm exec formly-contracts generate
pnpm exec formly-contracts check
EditChange the Formly factory, profile, or config.
GeneratePublish canonical contracts and the index.
ReviewInspect the semantic diff and diagnostics.
CheckFail CI if expected output is stale.

generate 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.json

Do not construct that encoded path yourself. Look up stable IDs in the index and open its recorded artifactPath:

workspace-index.json (focused excerpt)
{
"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.

Here is the generated node for the application-owned radio field. This is a formatted excerpt of the canonical shared.contact-preferences golden:

shared.contact-preferences · contactPreference node
{
"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"
}
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.

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-preference

For 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.

Actionable as context Identity, legal values, locator evidence, interaction, and wrapper precondition are explicit.
Refused for execution The current plan grammar cannot preserve the wrapper activation step, so no plan is returned.

For fields inside the currently supported synthetic proof slice, the remaining path is deliberately fail-closed:

  1. QueryResolve one usage or form against an exact artifact-set hash.
  2. ValidateCompile supported typed intent only when the field, value, state, and driver authority are actionable.
  3. PlanProduce a canonical plan and content hash bound to that exact context.
  4. RevalidateReject stale, ambiguous, changed, or newly refused authority before execution.
  5. BindResolve every approved step to the exact implementation in an authenticated, allowlist-bound local driver registry.

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.

The loop is deliberately boring. Change the real form. Regenerate. Review the semantic diff. Run 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.