DEV Community

Cover image for Make Angular Material dialogs type-safe
Robin Pellegrims
Robin Pellegrims

Posted on • Originally published at pellegrims.dev on

Make Angular Material dialogs type-safe

Introduction

Angular Material provides a MatDialogService that allows developers to easily integrate modal dialogs into their applications. If you're not familiar with Angular Material or its dialog service, check out the official documentation.

While this dialog service is very easy to use, the interaction between the MatDialogService and your dialog component isn't completely type-safe and could potentially lead to runtime issues.

Use case

Our use case is using Angular v13.3.8 and is based on this example in the official Angular Material documentation.

The dialog receives an input object representing a favorite animal:

export interface DialogData {
  animal: string;
}

Enter fullscreen mode Exit fullscreen mode

It then displays the animals in a dialog and asks the user to cancel or approve:

Dialog screenshot {252x235}

After clicking one of the buttons, the dialog returns a boolean representing the button that was clicked.

Implementation

Parent component

// template
<button mat-button (click)="openDialog()">Open dialog</button>


@Component({
  selector: 'app-component',
  // template: see above
})
export class AppComponent {
  constructor(public dialog: MatDialog) {}

  openDialog() {
    this.dialog
      .open(DialogComponent, { data: { animal: 'panda' } })
      .afterClosed()
      .pipe(tap((result) => console.log(result === true)))
      .subscribe();
  }
}

Enter fullscreen mode Exit fullscreen mode

Dialog component

// template
<h1 mat-dialog-title>Favorite Animal</h1>
<mat-dialog-content>
  <p>My favorite animal is "{{ data.animal }}".</p>
  <p>Do you approve?</p>
</mat-dialog-content>
<mat-dialog-actions>
  <button mat-button (click)="cancelClick()">Cancel</button>
  <button mat-button (click)="okClick()">Ok</button>
</mat-dialog-actions>


@Component({
  selector: 'example-dialog',
  // template: see above
})
export class DialogComponent {
  constructor(
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
    public dialogRef: MatDialogRef<DialogComponent>
  ) {}

  cancelClick = () => this.dialogRef.close(false);
  okClick = () => this.dialogRef.close(true);
}

Enter fullscreen mode Exit fullscreen mode

Type safety issues

While the dialog in our use case is currently working perfectly fine, the interaction between the parent and the dialog component is not completely type-safe.

Let's identify some possible issues that will still compile completely fine, but that would break at runtime.

Parent component

  • When opening the dialog, a typo could be made in the animal property name that is passed as the dialog data:
this.dialog.open(DialogComponent, { data: { annimal: 'panda' } });

Enter fullscreen mode Exit fullscreen mode
  • The parent component receives an untyped result from the afterClosed operator that could be used in a wrong way:
this.dialog
  .open(DialogComponent, { data: { animal: 'panda' } })
  .afterClosed()
  // this will always log false, since result is a boolean
  .pipe(tap((result) => console.log(result === 'true')))
  .subscribe();

Enter fullscreen mode Exit fullscreen mode

Dialog component

  • In the dialog component we might have forgotten about the correct type we were going to use and use a wrong type instead:
@Inject(MAT_DIALOG_DATA) public data: { favouriteAnimal: string }

Enter fullscreen mode Exit fullscreen mode
  • The dialog should return a boolean value after closing, but nothing currently prevents us from passing anything else when closing the dialog:
this.dialogRef.close('cancel');

Enter fullscreen mode Exit fullscreen mode

Parent-child type synchronization

There is no synchronization between the Data and Result types used in the parent component and the dialog component. This means the application will still compile when we use different types by mistake on both sides.

Adding generic params

A first step towards more type-safety would be to explicitly specify the types in the parent component when opening the dialog:

// parent component
this.dialog
  .open<DialogComponent, DialogData, boolean>(DialogComponent, {
    data: { animal: 'panda' },
  })
  .afterClosed()
  // result: boolean | undefined
  .pipe(tap((result) => console.log(result === true)))
  .subscribe();

Enter fullscreen mode Exit fullscreen mode

In the dialog component, we can force the result type when injecting the MatDialogRef:

// dialog component
constructor(
  @Inject(MAT_DIALOG_DATA) public data: DialogData,
  public dialogRef: MatDialogRef<DialogComponent, boolean>
) {}

Enter fullscreen mode Exit fullscreen mode

While these simple changes effectively force the developer to use correct dialog data and result objects, it still doesn't prevent using different Data/Result types in the parent and dialog component. It also has the drawback that the types need to be specified in both the parent and child component.

Using an abstract dialog component superclass

To increase type safety even further between the parent and dialog component and have a single source of truth for the dialog Data/Result types, a custom dialog service and abstract dialog component superclass can be created:

@Directive()
export abstract class StronglyTypedDialog<DialogData, DialogResult> {
  constructor(
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
    public dialogRef: MatDialogRef<
      StronglyTypedDialog<DialogData, DialogResult>,
      DialogResult
    >
  ) {}
}

@Injectable({ providedIn: 'root' })
export class DialogService {
  constructor(public dialog: MatDialog) {}

  open = <DialogData, DialogResult>(
    component: ComponentType<StronglyTypedDialog<DialogData, DialogResult>>,
    config?: MatDialogConfig<DialogData>
  ): MatDialogRef<
    StronglyTypedDialog<DialogData, DialogResult>,
    DialogResult
  > => this.dialog.open(component, config);
}

Enter fullscreen mode Exit fullscreen mode

Since the constructor has been moved to an abstract superclass, the dialog component can then be simplified like this:

@Component({
  selector: 'example-dialog',
  // template: unchanged, see above
})
export class DialogComponent extends StronglyTypedDialog<DialogData, boolean> {
  cancelClick = () => this.dialogRef.close(false);
  okClick = () => this.dialogRef.close(true);
}

Enter fullscreen mode Exit fullscreen mode

The dialog can then be opened through this new DialogService instead of the regular MatDialog service:

@Component({
  selector: 'app-component',
  // template: unchanged, see above
})
export class AppComponent {
  constructor(public dialog: DialogService) {}

  openDialog() {
    this.dialog
      .open(DialogComponent, { data: { animal: 'panda' } })
      .afterClosed()
      // result: boolean | undefined
      .pipe(tap((result) => console.log(result === true)))
      .subscribe();
  }
}

Enter fullscreen mode Exit fullscreen mode

This will achieve full type-safety on both the parent and dialog component while making the DialogComponent the single source of truth for the Data/Result types.

Summary

We explored multiple possible runtime issues when using Angular Material dialogs:

  • passing unexpected data into the dialog
  • returning unexpected results from the dialog to the parent component
  • using different data/result types in the parent and dialog component

Some of the issues can be addressed by adding generic params:

  • in the function call that opens the dialog
  • to the injected MatDialogRef in the dialog constructor

Finally, the open() method of the MatDialogService can be wrapped in a custom service that provides full type-safety and addresses all of the explored issues.

Top comments (0)