I work on an Angular 18 application. I need to imp...

تم الإنشاء في: ١٠ يونيو ٢٠٢٥

تمت الإجابة باستخدام O3 بواسطة Chat01

سؤال

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[],getProvinces: () => Country[], getProvinces: (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?

الفكرة

Advising on Angular 18 API Design

The user is asking about defining an "API" interface in Angular 18 for address editing. I'll explain whether that interface pattern is a good practice and mention alternatives like dependency injection and using typed Observables. I'll also discuss using reactive forms with Angular Material components for UI. I'll provide sample code to demonstrate how to create the API service interface, work with dependency injection tokens, and build a component for display, editing, and managing addresses effectively.

Evaluating API Interface Design Practice

The user’s approach to using an API interface with Observables and dependency injection sounds like a good practice for decoupling. It helps maintain asynchronous operations. We can suggest alternative patterns like using an injection token for the service or even an abstract class as an Angular provider. I'll prepare a sample with a typed reactive form, configuration for mandatory fields, and an example using Dependency Injection. I'll also propose renaming the service to something more purposeful, like AddressEditorDataSource. Lastly, I won't call out to external sources but will ensure the response stays up to date with Angular’s latest features.

Thought for 25 seconds

الإجابة

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:

  1. Represent the calls as Observables (or Promises) instead of raw return values so the component stays asynchronous by design.
  2. Inject the façade via Angular’s DI system (an 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.
  3. Keep the token’s contract narrow and focused on the dialog’s needs—e.g. “give me country options, province options, and save”.

Below is a skeleton that puts those ideas together and shows how the required-field configuration and dual “display / edit” modes plug in.


1. Core types

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

2. The data-source contract & injection token

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?

  • Dialog-agnostic: the dialog only “knows” about the interface, not which service satisfies it.
  • Re-usability: parent features (checkout, admin portal, etc.) register different providers—no extra @Input() plumbing.
  • Testability: unit tests swap in a stub/mock easily.

3. Host component: view vs. edit

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>

4. Editor dialog (typed reactive form)

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>

5. Providing the API implementation

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:

ts
beforeEach(() => { TestBed.configureTestingModule({ providers: [ { provide: ADDRESS_EDITOR_API, useClass: MockAddressEditorApi } ] }); });

Is defining that AddressEditorApi interface good practice?

Yes—provided you:

❌ anti-pattern✅ preferred
Put concrete HttpClient logic inside the dialogKeep 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 synchronouslyReturn Observables (or Promises) so the UI naturally reacts to async streams.
Couple validation rules in two placesBuild validators dynamically from the required map (single source of truth).

With this structure the component tree stays highly reusable:

  • Different contexts (billing vs shipping vs reseller) send different required maps and titles.
  • Different modules register different providers for the token (REST, GraphQL, IndexedDB, mock, etc.).
  • Unit tests stub out the service without touching templates.

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?

الفكرة

Troubleshooting NullInjectorError

The NullInjectorError likely arises because of how Angular handles injectors hierarchically. When you use MatDialog.open, the injected component's providers might differ from the dialog's context because the dialog component follows the root or module injection, not the component's. I'll double-check: to achieve proper injection, it might be necessary to provide the token at the module level or explicitly pass a custom injector using MatDialogConfig. This ensures the dialog component can access the correct provider.

Explaining the provider issue

The error occurs because the ADDRESS_EDITOR_API is provided at the component level, but that provider doesn't reach the dynamically created dialog component. When using MatDialog.open, the component is created with the root injector, not the component's local injector. To fix this, you can either:

  1. Provide the token at the module level (either a feature or application module).
  2. Pass a custom injector when opening the dialog.

Ensure the service (TestService) is provided correctly as well to avoid a NullInjectorError.

Thought for 10 seconds

الإجابة

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!

Fix 1 – register the token at module (or root) scope

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.

Fix 2 – supply a custom injector when you open the dialog

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

Why it happens

  • Component-level providers live in the element injector for that component.
  • Dynamically created components (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).
  • Therefore any token you want the dialog to resolve must be available in—​or inherited by—​that 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.

الفكرة

Explaining Angular injector issue

The problem likely stems from not correctly providing TestService in the child injector. Even though you used Injector.create, you still need to ensure TestService is a provider for the child injector, especially when using useExisting. MatDialogConfig's injector parameter works in newer versions (15+), but it needs proper setup for both the host and dialog. You can specify additional providers with parent: this.injector to resolve this issue, although the child needs TestService too for useExisting to function as expected.

Clarifying Angular injection issue

In Angular, when using Injector.create, you can't directly reference a token in the same create call with useExisting. Instead, you'd need to pass both tokens explicitly in the providers list. If TestService is only available in the component's provider and not the root, the child injector can't find it. Ensuring TestService is added to the providedIn: 'root' or the parent injector should resolve the issue. It seems they forgot to provide TestService or mistakenly referenced an alias without having the correct token.

Thought for 15 seconds

الإجابة

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:

  1. The injector that builds the dialog must actually contain a provider for the token.
  2. If that provider is an alias (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.


✔️ Option A — supply the provider right in the 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).
  • You’re using Angular Material 17 or newer so the providers option exists.

✔️ Option B — build a child injector that actually contains the alias

If you prefer the manual‐injector route, the child injector must include the alias provider, not just rely on the parent:

ts
open(): 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.


Why “module–level” providers still work for multiple implementations

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.


Quick checklist

✔︎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.

مشاركة هذا السؤال والجواب