Wrappers travel through the library as registered types — a WrapperTypeDefinition with a lazy-loaded component. Registration tells provideDynamicForm(...) how to resolve a config like { type: 'section' } to your component class, and how to type-check wrapper configs in form definitions.
1. Register with createWrappers
createWrappers(...) returns a branded bundle that provideDynamicForm(...) recognises:
import { createWrappers, wrapperProps } from '@ng-forge/dynamic-forms';
import type { SectionWrapper } from './section-wrapper.component';
export const appWrappers = createWrappers({
wrapperName: 'section',
loadComponent: () => import('./section-wrapper.component'),
props: wrapperProps<SectionWrapper>(),
});wrapperProps<T>() is a zero-cost type carrier — it returns undefined at runtime and exists purely so TypeScript can thread the config type T into the bundle.
2. Augment FieldRegistryWrappers once
Declare the augmentation in the same file you registered the wrapper. InferWrapperRegistry<typeof appWrappers> does the bookkeeping:
import type { InferWrapperRegistry } from '@ng-forge/dynamic-forms';
declare module '@ng-forge/dynamic-forms' {
interface FieldRegistryWrappers extends InferWrapperRegistry<typeof appWrappers> {}
}Add new entries to createWrappers(...) and the augmentation updates itself. Config objects everywhere in the app now autocomplete and type-check { type: 'section', title: '…' }.
3. Pass the bundle to provideDynamicForm
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';
import { appWrappers } from './app-wrappers';
export const appConfig: ApplicationConfig = {
providers: [provideDynamicForm(...withMaterialFields(), appWrappers)],
};
provideDynamicForm(...)is an application-level provider — call it once inApplicationConfig.providers(or the equivalentbootstrapApplicationproviders). It is not intended forRoute.providersand the library does not support merging or overriding the wrapper registry per route. Register every field type and wrapper the app needs in that single call.
4. Apply wrappers
Three ways to reach a wrapper, in order of increasing scope:
Per-field
Set wrappers on any field:
{
key: 'contact',
type: 'input',
label: 'Contact name',
wrappers: [{ type: 'section', title: 'Primary contact' }],
}Multiple wrappers stack outermost → innermost. The first entry is the outermost. Mixing a custom wrapper with the built-in css:
// section wraps css wraps the field. `section` must be registered first
// (see Writing a Wrapper). `css` is built-in.
wrappers: [
{ type: 'section', title: 'Card' }, // outer
{ type: 'css', cssClasses: 'muted' }, // inner
];Form-wide defaults
Set defaultWrappers on FormConfig to apply a chain to every field that does not opt out:
const formConfig: FormConfig = {
defaultWrappers: [{ type: 'css', cssClasses: 'demo-field' }],
fields: [
{ key: 'email', type: 'input', label: 'Email', required: true },
{ key: 'notes', type: 'input', label: 'Notes', wrappers: null },
],
};notes skips the defaults entirely via wrappers: null.
Auto-association by field type
A WrapperTypeDefinition can declare types: ['input', 'select'] to apply automatically wherever those field types render:
createWrappers({
wrapperName: 'floatingLabel',
loadComponent: () => import('./floating-label-wrapper.component'),
types: ['input', 'textarea'],
});Every input and textarea across the app now receives the floatingLabel wrapper without touching field config.
Merge order
The effective wrapper chain for one field is merged from three sources, outermost → innermost:
- Auto-association — wrappers whose
typesarray includes the field'stype - Form defaults —
FormConfig.defaultWrappers - Field-level — the field's own
wrappersarray
wrappers state cheatsheet
wrappers value |
Effect |
|---|---|
undefined |
Inherit (auto-associations + defaults apply) |
null |
Opt out — render the field bare |
[] |
Inherit (same as undefined — not an opt-out) |
[{ …wrapper }] |
Append to auto-associations + defaults |
wrappers: [](an empty array) is not an opt-out. Auto-associations anddefaultWrappersstill apply — the field-level list just adds zero additional wrappers. Usewrappers: nullto skip them entirely.
Interactive example
The form below sets defaultWrappers: [{ type: 'css', cssClasses: 'demo-field' }]. The contact field layers a section wrapper on top of the default, and notes opts out entirely with wrappers: null.
Troubleshooting
- Wrapper does not render. Check that it's passed to
provideDynamicForm(...)—WRAPPER_REGISTRYhas no entry otherwise, and the outlet logs anerror-level message viaDynamicFormLoggerplus aconsole.errorwith the[Dynamic Forms]prefix. fieldComponentisundefinedin the wrapper's constructor. Expected — it's a view query. Read it inside acomputed()/effect()/ template, never in the constructor.- Wrapper config isn't typed. Confirm the
declare moduleblock runs (TypeScript only picks up augmentations from files that are actually imported). Re-exportingappWrappersfrom an entry module is enough. - Typed config prop does nothing. A typo like
tilteinstead oftitlewon't throw — the wrapper renders without that prop. Unknown keys are intentionally dropped so wrappers don't fight over unrelated config. Rely on the TypeScript augmentation to catch the typo at the config site. - Wrapper re-renders on every keystroke. Expected when the wrapper reads a mapper-driven input directly — read only the signals you need inside a
computed()and rely on signal equality to short-circuit downstream reactivity.
Where to go from here
- Back to Writing a wrapper for the component contract and field-state reading patterns.
- Recipes → Adding Custom Fields when a new wrapper isn't enough — you need a brand-new control.