I work on an Angular 18 application. I need to imp...
Erstellt am: 10. Juni 2025
Erstellt am: 10. Juni 2025
I work on an Angular 18 application. I need to implement an address editor based on the following interface:
export interface Address {
email?: string;
firstName?: string;
lastName?: string;
company?: string;
address?: string;
city?: string;
country?: string;
province?: string;
zip?: string;
phone?: string;
fax?: string;
}
The editor needs to use a reactive Angular typed form.
The editor should receive a configuration object that dictates which fields are mandatory to be filled in.
The editor should receive a string title for the address, i.e. Billing information, Shipping information, Reseller information, etc.
The editor should have two modes, display mode and edit mode.
When in display mode, it should display the address as a simple readonly card.
If no address is fed into the editor, it should display a centered button "Add billing information".
If an address is fed into the editor, it should display an "Edit" button. Clicking the "Edit" button switches the editor in edit mode.
When switching to the edit mode, an Angular Material Dialog is opened that presents the address form.
The address form must use Angular Material Input fields for all of the fields except country and province, which must use Angular Material Autocomplete.
The dialog has to retrieve the list of countries.
When the operator picks a country, the dialog has to retrieve the corresponding provinces list.
The dialog has to provide a cancel button.
The dialog has to provide a save button.
When the save button is clicked, the address must be save to the database.
The dialog should be agnostic of all the asynchronous operations, i.e. it should not know where the API calls are going, or if they even reach an API at all.
I was thinking of defining an "API" interface along the lines of
interface API {
getCountries: (country: string) => Province[],
save$: (address: Address) => void
}
and use this interface as the type of an input object, to force each consumer to provide an implementation. Is this a good practice?
Using a small “API façade” is a solid way to keep the dialog completely agnostic of where data comes from or where it is saved—but you’ll get the most mileage if you:
Observable
s (or Promise
s) instead of raw return values so the component stays asynchronous by design.InjectionToken
) instead of passing a concrete object through @Input()
. That leaves the template clean and lets consumers provide real or mock implementations at module-config time.Below is a skeleton that puts those ideas together and shows how the required-field configuration and dual “display / edit” modes plug in.
ts// address.model.ts ------------------------------------- export interface Address { email?: string; firstName?: string; lastName?: string; company?: string; address?: string; city?: string; country?: string; province?: string; zip?: string; phone?: string; fax?: string; } export interface Country { code: string; name: string; } export interface Province { code: string; name: string; } /** Which fields must be filled in for *this* usage of the editor */ export type AddressRequiredMap = Partial<Record<keyof Address, boolean>>;
ts// address-editor-api.ts -------------------------------- import { Observable } from 'rxjs'; export interface AddressEditorApi { getCountries(): Observable<Country[]>; // → async stream getProvinces(countryCode: string): Observable<Province[]>; save(address: Address): Observable<void>; } /** Unique DI key so apps can plug in – or mock – implementations */ import { InjectionToken } from '@angular/core'; export const ADDRESS_EDITOR_API = new InjectionToken<AddressEditorApi>( 'ADDRESS_EDITOR_API' );
Why use an InjectionToken
?
@Input()
plumbing.ts// address-card.component.ts ---------------------------- @Component({ selector: 'app-address-card', templateUrl: './address-card.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddressCardComponent { @Input() title = 'Billing information'; @Input() address: Address | null = null; @Input() required: AddressRequiredMap = {}; constructor(private dialog: MatDialog) {} openEditor(): void { this.dialog.open(AddressDialogComponent, { width: '560px', data: { address: this.address, required: this.required, title: this.title } }) .afterClosed() .subscribe(result => { if (result) { this.address = result; } // optimistic update }); } }
html<!-- address-card.component.html --> <ng-container *ngIf="address; else empty"> <mat-card> <mat-card-title>{{ title }}</mat-card-title> <mat-card-content class="space-y-1"> <div>{{ address.firstName }} {{ address.lastName }}</div> <div *ngIf="address.company">{{ address.company }}</div> <div>{{ address.address }}</div> <div>{{ address.city }}, {{ address.province }} {{ address.zip }}</div> <div>{{ address.country }}</div> <div *ngIf="address.phone">📞 {{ address.phone }}</div> <div *ngIf="address.fax">📠 {{ address.fax }}</div> </mat-card-content> <mat-card-actions align="end"> <button mat-stroked-button color="primary" (click)="openEditor()">Edit</button> </mat-card-actions> </mat-card> </ng-container> <ng-template #empty> <div class="flex justify-center"> <button mat-stroked-button color="primary" (click)="openEditor()"> Add {{ title.toLowerCase() }} </button> </div> </ng-template>
ts// address-dialog.component.ts -------------------------- @Component({ templateUrl: './address-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddressDialogComponent implements OnInit { form!: FormGroup<{ email: FormControl<string | null>; firstName: FormControl<string | null>; lastName: FormControl<string | null>; company: FormControl<string | null>; address: FormControl<string | null>; city: FormControl<string | null>; country: FormControl<string | null>; province: FormControl<string | null>; zip: FormControl<string | null>; phone: FormControl<string | null>; fax: FormControl<string | null>; }>; countryOptions$ = this.api.getCountries(); provinceOptions$!: Observable<Province[]>; constructor( @Inject(MAT_DIALOG_DATA) public data: { address: Address | null; required: AddressRequiredMap; title: string }, private fb: FormBuilder, private dialogRef: MatDialogRef<AddressDialogComponent>, @Inject(ADDRESS_EDITOR_API) private api: AddressEditorApi ) {} ngOnInit(): void { const a = this.data.address ?? {}; this.form = this.fb.group({ email: [a.email ?? null, this.makeValidators('email')], firstName: [a.firstName ?? null, this.makeValidators('firstName')], lastName: [a.lastName ?? null, this.makeValidators('lastName')], company: [a.company ?? null, this.makeValidators('company')], address: [a.address ?? null, this.makeValidators('address')], city: [a.city ?? null, this.makeValidators('city')], country: [a.country ?? null, this.makeValidators('country')], province: [a.province ?? null, this.makeValidators('province')], zip: [a.zip ?? null, this.makeValidators('zip')], phone: [a.phone ?? null, this.makeValidators('phone')], fax: [a.fax ?? null, this.makeValidators('fax')], }); /* Chain province options to currently selected country */ this.provinceOptions$ = this.form.controls.country.valueChanges.pipe( switchMap(code => code ? this.api.getProvinces(code) : of([])) ); } private makeValidators(key: keyof Address): ValidatorFn[] { return this.data.required[key] ? [Validators.required] : []; } cancel(): void { this.dialogRef.close(); } save(): void { if (this.form.invalid) return; this.api.save(this.form.getRawValue() as Address).subscribe({ next: () => this.dialogRef.close(this.form.getRawValue()), error: err => console.error(err) // TODO: surface error to user }); } }
html<!-- address-dialog.component.html --> <h2 mat-dialog-title>{{ data.title }}</h2> <mat-dialog-content [formGroup]="form" class="flex flex-col gap-4 mt-2"> <mat-form-field appearance="outline"> <mat-label>Email</mat-label> <input matInput formControlName="email" type="email"> </mat-form-field> <!-- … repeat for firstName, lastName, company, address, city, zip, phone, fax … --> <!-- Country autocomplete --> <mat-form-field appearance="outline"> <mat-label>Country</mat-label> <input type="text" matInput formControlName="country" [matAutocomplete]="autoCountry"> <mat-autocomplete #autoCountry="matAutocomplete"> <mat-option *ngFor="let c of countryOptions$ | async" [value]="c.code"> {{ c.name }} </mat-option> </mat-autocomplete> </mat-form-field> <!-- Province autocomplete (filtered by country) --> <mat-form-field appearance="outline"> <mat-label>Province</mat-label> <input type="text" matInput formControlName="province" [matAutocomplete]="autoProv"> <mat-autocomplete #autoProv="matAutocomplete"> <mat-option *ngFor="let p of provinceOptions$ | async" [value]="p.code"> {{ p.name }} </mat-option> </mat-autocomplete> </mat-form-field> </mat-dialog-content> <mat-dialog-actions align="end"> <button mat-button (click)="cancel()">Cancel</button> <button mat-flat-button color="primary" (click)="save()" [disabled]="form.invalid"> Save </button> </mat-dialog-actions>
ts// checkout.module.ts (example feature) ----------------- @NgModule({ declarations: [AddressCardComponent, AddressDialogComponent], imports: [ // … Angular Material & ReactiveFormsModule … ], providers: [ { provide: ADDRESS_EDITOR_API, useExisting: CheckoutApiService // your REST/GraphQL façade } ] }) export class CheckoutModule {}
In tests:
tsbeforeEach(() => { TestBed.configureTestingModule({ providers: [ { provide: ADDRESS_EDITOR_API, useClass: MockAddressEditorApi } ] }); });
AddressEditorApi
interface good practice?Yes—provided you:
❌ anti-pattern | ✅ preferred |
---|---|
Put concrete HttpClient logic inside the dialog | Keep the dialog dumb; inject any service matching AddressEditorApi . |
Pass a plain object of callbacks through @Input() | Use Angular DI with an InjectionToken (cleaner templates, easier to override). |
Return arrays synchronously | Return Observable s (or Promise s) so the UI naturally reacts to async streams. |
Couple validation rules in two places | Build validators dynamically from the required map (single source of truth). |
With this structure the component tree stays highly reusable:
required
maps and titles.That keeps your address editor portable, testable, and future-proof.
I have followed the above suggestion of using an injection token but I get a runtime error "NullInjectorError: No provider for InjectionToken ADDRESS_EDITOR_API!"
So I've defined a new 'address' feature, in it I've defined the AddressEditorModalComponent which is the modal. In the modal I've injected the token. The modal is opened from another component in my application for testing purposes. Next to that component I've defined a TestService which implements the AddressEditorApi interface, and in that component I've provided the token:
providers: [
{
provide: ADDRESS_EDITOR_API,
useExisting: TestService,
},
]
What am I doing wrong?
ADDRESS_EDITOR_API
is registered, but only inside the element-injector that belongs to the “test” component.
When you open a dialog, Angular does not create the dialog component as a child of that component, it asks MatDialog
to build it with the root injector (unless you give it a custom one).
Because of that, the dialog can’t see the provider you added at component level and you get
NullInjectorError: No provider for InjectionToken ADDRESS_EDITOR_API!
Put the alias provider in the module that declares the dialog (or in any module that ends up in the same injector hierarchy as the dialog):
ts@NgModule({ declarations: [ AddressEditorModalComponent, TestHostComponent // the component that opens the dialog ], providers: [ TestService, // if not already providedIn:'root' { provide: ADDRESS_EDITOR_API, useExisting: TestService } ] }) export class AddressFeatureModule {}
Because the module’s provider lives in the same injector the dialog will use (the feature injector, or the root injector if it’s a singleton), the token is now resolvable.
If you really need the provider to stay local to the host component you can hand MatDialog an injector that extends the host component’s injector:
ts@Component({ ... }) export class TestHostComponent { constructor( private dialog: MatDialog, private injector: Injector, // host component’s injector ) {} open(): void { const childInjector = Injector.create({ providers: [ { provide: ADDRESS_EDITOR_API, useExisting: TestService }, ], parent: this.injector, }); this.dialog.open(AddressEditorModalComponent, { injector: childInjector, }); } }
(You may still need TestService
in providers
or providedIn: 'root'
so the alias has something concrete to point at.)
providers
live in the element injector for that component.MatDialog
, ComponentFactoryResolver
, Router
outlets, etc.) are not inserted under the element injector tree; they get the injector you pass (or fall back to the root injector).Choose the approach that best matches your architecture; most of the time putting the provider in the feature module is the simplest and clearest solution.
Using the first fix kind of negates the whole purpose of providing different implementations, depending on the context. And the second approach did not work. I've created the child injector in which I've provided the token and passed this new injector to the dialog.
In Angular 18 the dialog doesn’t inherit an element-injector tree—it uses the injector you give it (or the root one).
That means two things have to be true before the dialog can resolve your ADDRESS_EDITOR_API
token:
useExisting
), the injector also has to contain—or be able to inherit—a provider for the service you alias to (TestService
).In the code you described only (2) is true; you never put any provider for
ADDRESS_EDITOR_API
in the injector that reaches the dialog, so Angular throws
NullInjectorError
.
Below are two reliable ways to solve it—pick the one that fits your use-case.
MatDialog
call (Angular 17+)Since Angular 17 MatDialogConfig
gained a providers
property.
Anything you list there is made available only to that dialog instance.
ts@Component({ ... }) export class TestHostComponent { constructor( private dialog: MatDialog, public testApi: TestService // ensure TestService itself is provided ) {} open(): void { this.dialog.open(AddressEditorModalComponent, { width: '560px', data: { /* ... */ }, providers: [ // alias the already-available TestService to the token { provide: ADDRESS_EDITOR_API, useExisting: TestService } ] }); } }
What you need to check
TestService
itself is available (either providedIn: 'root'
or in the
component’s providers: [TestService]
list).providers
option exists.If you prefer the manual‐injector route, the child injector must include the alias provider, not just rely on the parent:
tsopen(): void { const childInjector = Injector.create({ parent: this.injector, // component’s own injector providers: [ // Make sure TestService is visible (either here or in parent) { provide: TestService, useClass: TestService, deps: [] }, // Now the alias can resolve { provide: ADDRESS_EDITOR_API, useExisting: TestService } ] }); this.dialog.open(AddressEditorModalComponent, { injector: childInjector }); }
Notice we added a provider record for the alias itself.
Without that record Angular simply doesn’t know that ADDRESS_EDITOR_API
exists at all.
If you structure each context (Billing, Shipping, Reseller, …) as its own feature module, you can keep different implementations totally separate:
ts@NgModule({ declarations: [AddressEditorModalComponent], providers: [ { provide: ADDRESS_EDITOR_API, useClass: BillingAddressApi } // module-specific ] }) export class BillingModule {}
Loading BillingModule lazily (or in a route specific to billing) means that
dialog instances created from that module automatically pick up that
implementation, while dialogs opened from ShippingModule get the shipping
one, etc.
So “module provider” does not negate contextual override—it just scopes the
override at module boundaries instead of per-component.
✔︎ | Have you… |
---|---|
provided TestService somewhere that’s visible to the dialog? | |
added a provider for ADDRESS_EDITOR_API in the same injector the dialog uses (either via providers: in MatDialog.open or in the child injector list)? | |
(Angular 18) considered the simpler providers: property on MatDialogConfig instead of a manual injector? |
Set the provider in the injector that builds the dialog, and the error disappears.